Files
vpn-proxy/src/server/index.js

1456 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { spawn, spawnSync } from "node:child_process";
import { settings } from "./config.js";
import { fetchSubscription } from "./subscription.js";
import os from "node:os";
import {
buildGatewayConfig,
writeSingboxConfig,
readSingboxConfig,
removeSingboxConfig,
} from "./singbox.js";
import {
legacyDeviceRulesFromProfiles,
readDeviceProfiles,
writeDeviceProfiles,
} from "./devices.js";
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
import { tcpPing, resolveHost } from "./ping.js";
const APPLY_HISTORY_LIMIT = 10;
const RULE_SET_TAG_RE = /^[a-z0-9][a-z0-9_.@!-]*$/i;
const FALLBACK_RULE_SET_CATALOG = {
geosite: [
"geosite-category-ru",
"geosite-category-ai-!cn",
"geosite-geolocation-!cn",
"geosite-google",
"geosite-youtube",
"geosite-telegram",
"geosite-openai",
"geosite-apple",
"geosite-github",
"geosite-steam",
"geosite-discord",
"geosite-netflix",
"geosite-cloudflare",
"geosite-category-ads-all",
],
geoip: [
"geoip-ru",
"geoip-cloudflare",
"geoip-telegram",
"geoip-google",
"geoip-netflix",
"geoip-private",
],
};
fs.mkdirSync(settings.dataDir, { recursive: true });
const SINGBOX_PID_FILE = path.join(settings.dataDir, "singbox.pid");
// ─── Direct bypass cache (ipset) ────────────────────────────────────────────
const DIRECT_BYPASS_SET = process.env.DIRECT_BYPASS_SET || "vpn_direct_bypass";
const DIRECT_BYPASS_TTL = process.env.DIRECT_BYPASS_TTL || "3600";
const DIRECT_BYPASS_CACHE = process.env.DIRECT_BYPASS_CACHE === "true";
const IPSET_AVAILABLE = (() => {
try {
const result = spawnSync("ipset", ["version"], { timeout: 1000 });
return !result.error && result.status === 0;
} catch {
return false;
}
})();
const IP_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/;
// Локальный счётчик добавленных IP (RAM-only, сбрасывается при перезапуске)
let directBypassCount = 0;
function addToDirectBypass(ip) {
if (!DIRECT_BYPASS_CACHE || !IPSET_AVAILABLE || !IP_RE.test(ip)) return;
try {
spawnSync(
"ipset",
["add", DIRECT_BYPASS_SET, ip, "timeout", DIRECT_BYPASS_TTL, "-exist"],
{
timeout: 500,
},
);
directBypassCount++;
} catch {}
}
function flushDirectBypass() {
directBypassCount = 0;
if (!IPSET_AVAILABLE) return;
try {
spawnSync("ipset", ["flush", DIRECT_BYPASS_SET], { timeout: 1000 });
} catch {}
}
function listDirectBypass() {
if (!DIRECT_BYPASS_CACHE || !IPSET_AVAILABLE) return [];
try {
const result = spawnSync(
"ipset",
["list", DIRECT_BYPASS_SET, "-output", "plain"],
{
encoding: "utf8",
timeout: 2000,
},
);
const lines = (result.stdout || "").split("\n");
// После строки "Members:" идут IP-адреса
const membersIdx = lines.findIndex((l) => l.trim() === "Members:");
if (membersIdx === -1) return [];
return lines
.slice(membersIdx + 1)
.map((l) => l.trim().split(" ")[0])
.filter((l) => IP_RE.test(l));
} catch {
return [];
}
}
let singboxProcess = null;
let singboxStartedAt = null;
const LOG_BUFFER_SIZE = 500;
const logBuffer = [];
const logSubscribers = new Set();
const TRAFFIC_BUFFER_SIZE = 500;
const trafficBuffer = [];
const trafficSubscribers = new Set();
// Паттерны для парсинга трафика из логов sing-box.
// Форматы логов sing-box:
// [TCP] 192.168.1.1:PORT --> example.com:443 outbound/direct[direct]
// [UDP] 192.168.1.1:PORT --> 8.8.8.8:53 outbound/direct[direct]
// [router] match[N][rule-name] => outbound/direct[tag]
// outbound/direct[tag]: dial tcp connection to host:port
// Назначение после --> (старый формат sing-box)
const DEST_ARROW_RE = /-->\s*([\w.\-]+):(\d{1,5})/;
// Назначение в словесном стиле
const DEST_WORD_RE =
/(?:connection\s+to|dial(?:ing)?|connect(?:ing)?\s+to)\s+([\w.\-]+):(\d{1,5})/i;
// Тип аутбаунда: outbound/TYPE[tag] или outbound/TYPE
const OUTBOUND_RE = /outbound\/([a-z0-9_\-]+)/i;
// Строка роутера: [router] match[N][rule-name] => outbound/TYPE[tag]
const ROUTER_MATCH_LINE_RE =
/\[router\].*\bmatch\[\d+\]\[([^\]]+)\].*outbound\/([a-z0-9_\-]+)/i;
// ID соединения: [CONN_ID Nms]
const CONN_ID_RE = /\[(\d{5,12})\s+\d+ms\]/;
// Входящее соединение от устройства: inbound [packet] connection from IP:PORT
const INBOUND_FROM_RE =
/inbound(?:\s+packet)?\s+connection\s+from\s+([\d.]+):\d+/i;
// Source IP из --> формата: IP:PORT -->
const SOURCE_ARROW_RE = /\b([\d.]+):\d+\s+-->/;
// Карта source IP по ID соединения
const CONN_TTL_MS = 10_000;
const connSourceMap = new Map();
setInterval(() => {
const now = Date.now();
for (const [id, v] of connSourceMap) {
if (now - v.at > CONN_TTL_MS) connSourceMap.delete(id);
}
}, 30_000);
// Хранит имя последнего правила из [router] строки (для следующей строки с dest)
let _pendingRuleName = null;
let _pendingRuleAt = 0;
const RULE_CONTEXT_TTL_MS = 500;
function parseTrafficLine(line) {
// Расширенная очистка ANSI (включая многопараметрические: \x1b[38;5;Nm)
const clean = line.replace(/\x1b\[[0-9;]*m/g, "").trim();
// Детектируем строку роутера — содержит правило, но не dest
const routerM = clean.match(ROUTER_MATCH_LINE_RE);
if (routerM) {
_pendingRuleName = routerM[1];
_pendingRuleAt = Date.now();
return null;
}
// Извлекаем ID соединения для корреляции
const connM = clean.match(CONN_ID_RE);
const connId = connM ? connM[1] : null;
// Строка "inbound connection from IP:PORT" — сохраняем source IP и выходим
const inboundFromM = clean.match(INBOUND_FROM_RE);
if (inboundFromM) {
if (connId)
connSourceMap.set(connId, { sourceIp: inboundFromM[1], at: Date.now() });
return null;
}
// Берём накопленное имя правила, если свежее
let inheritedRule = null;
if (_pendingRuleName && Date.now() - _pendingRuleAt < RULE_CONTEXT_TTL_MS) {
inheritedRule = _pendingRuleName;
}
_pendingRuleName = null;
_pendingRuleAt = 0;
// Ищем outbound
const obM = clean.match(OUTBOUND_RE);
if (!obM) return null;
const outboundRaw = obM[1].toLowerCase();
// Пропускаем DNS-аутбаунды
if (outboundRaw === "dns-out" || outboundRaw === "dns") return null;
let category;
if (outboundRaw === "direct" || outboundRaw.startsWith("direct-"))
category = "direct";
else if (outboundRaw === "block" || outboundRaw === "reject")
category = "block";
else category = "vpn";
// Ищем назначение: --> (старый формат), потом словесный (inbound/outbound connection to)
const destM = clean.match(DEST_ARROW_RE) || clean.match(DEST_WORD_RE);
if (!destM) return null;
const host = destM[1];
const port = parseInt(destM[2], 10);
// Source IP: из корреляционной карты (новый формат) или из --> (старый формат)
let sourceIp = null;
if (connId) {
const stored = connSourceMap.get(connId);
if (stored && Date.now() - stored.at < CONN_TTL_MS)
sourceIp = stored.sourceIp;
}
if (!sourceIp) {
const srcM = clean.match(SOURCE_ARROW_RE);
if (srcM) sourceIp = srcM[1];
}
return {
ts: new Date().toISOString(),
outbound: outboundRaw,
category,
host,
port,
sourceIp,
matchedRule: inheritedRule,
};
}
function pushTrafficEntry(entry) {
trafficBuffer.push(entry);
if (trafficBuffer.length > TRAFFIC_BUFFER_SIZE) trafficBuffer.shift();
for (const sub of trafficSubscribers) {
try {
sub(entry);
} catch {}
}
}
function pushLog(level, line) {
const entry = { ts: new Date().toISOString(), level, line };
logBuffer.push(entry);
if (logBuffer.length > LOG_BUFFER_SIZE) logBuffer.shift();
for (const subscriber of logSubscribers) {
try {
subscriber(entry);
} catch {}
}
// Парсим трафик из info/debug строк
if (level === "info" || level === "debug") {
const traffic = parseTrafficLine(line);
if (traffic) {
pushTrafficEntry(traffic);
// Если direct и назначение — IP, добавляем в bypass-кэш
if (traffic.category === "direct" && IP_RE.test(traffic.host)) {
addToDirectBypass(traffic.host);
}
} else if (
(level === "info" || level === "debug") &&
_debugUnparsed < 30 &&
/\bTCP\b|\bUDP\b|\boutbound\b/i.test(line.replace(/\x1b\[\d+m/g, ""))
) {
_debugUnparsed++;
process.stdout.write(
`[traffic:unmatched] ${line.replace(/\x1b\[\d+m/g, "").trim()}\n`,
);
}
}
}
let _debugUnparsed = 0;
// Sing-box пишет все логи в stderr, поэтому парсим уровень из содержимого строки.
// Формат: ESC[<n>m LEVEL ESC[0m, где ESC = \x1b
const SINGBOX_LEVEL_RE =
/\x1b\[\d+m(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)\x1b\[0m/i;
function parseSingboxLevel(line, fallback) {
const m = line.match(SINGBOX_LEVEL_RE);
if (!m) return fallback;
const l = m[1].toLowerCase();
if (l === "warn") return "warning";
if (l === "fatal") return "error";
return l; // trace, debug, info, error
}
// ─── PID helpers ────────────────────────────────────────────────────────────
function saveSingboxPid(pid) {
try {
fs.writeFileSync(SINGBOX_PID_FILE, String(pid), "utf8");
} catch {}
}
function readSingboxPid() {
try {
const pid = parseInt(fs.readFileSync(SINGBOX_PID_FILE, "utf8").trim(), 10);
return Number.isFinite(pid) && pid > 0 ? pid : null;
} catch {
return null;
}
}
function removeSingboxPid() {
try {
fs.unlinkSync(SINGBOX_PID_FILE);
} catch {}
}
function isPidAlive(pid) {
if (!pid) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/**
* Подхватывает уже запущенный sing-box по PID — без перезапуска.
* Логи недоступны (процесс запущен раньше), но kill/stop работает.
*/
function attachExistingSingbox(pid) {
const stateData = readJson(settings.statePath, {});
singboxStartedAt = stateData.appliedAt || new Date().toISOString();
let exitCb = null;
singboxProcess = {
pid,
kill: (sig = "SIGTERM") => {
try {
process.kill(pid, sig);
} catch {}
},
once: (event, cb) => {
if (event === "exit") exitCb = cb;
},
};
// Периодически проверяем, что процесс ещё жив
const watcher = setInterval(() => {
if (!isPidAlive(pid)) {
clearInterval(watcher);
if (singboxProcess?.pid === pid) {
singboxProcess = null;
singboxStartedAt = null;
removeSingboxPid();
pushLog("warning", `sing-box (pid=${pid}) завершился`);
}
if (exitCb) {
exitCb(null, null);
exitCb = null;
}
}
}, 2000);
pushLog("info", `sing-box подхвачен при старте (pid=${pid})`);
}
function captureStream(stream, fallbackLevel) {
let remainder = "";
stream.setEncoding("utf8");
stream.on("data", (chunk) => {
const data = remainder + chunk;
const lines = data.split(/\r?\n/);
remainder = lines.pop() || "";
for (const line of lines) {
if (!line) continue;
const level = parseSingboxLevel(line, fallbackLevel);
process.stdout.write(`[sing-box:${level}] ${line}\n`);
pushLog(level, line);
}
});
stream.on("end", () => {
if (remainder) {
const level = parseSingboxLevel(remainder, fallbackLevel);
process.stdout.write(`[sing-box:${level}] ${remainder}\n`);
pushLog(level, remainder);
}
remainder = "";
});
}
function readJson(filePath, fallback) {
try {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return fallback;
}
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
}
function maskSubscriptionUrl(url) {
if (!url) return "";
try {
const parsed = new URL(url);
return `${parsed.hostname}/...`;
} catch {
return url.length > 32 ? `${url.slice(0, 32)}...` : url;
}
}
function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload, null, 2);
res.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(body),
});
res.end(body);
}
function normalizeRuleSetUrl(url) {
const value = String(url || "").trim();
if (!value) return [];
const urls = [value];
const jsdelivrMatch = value.match(
/^https:\/\/cdn\.jsdelivr\.net\/gh\/([^/]+)\/([^@/]+)@([^/]+)\/(.+)$/i,
);
if (jsdelivrMatch) {
const [, owner, repo, ref, filePath] = jsdelivrMatch;
urls.push(
`https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath}`,
);
}
const rawMatch = value.match(
/^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/i,
);
if (rawMatch) {
const [, owner, repo, ref, filePath] = rawMatch;
urls.push(`https://cdn.jsdelivr.net/gh/${owner}/${repo}@${ref}/${filePath}`);
}
return Array.from(new Set(urls));
}
function downloadFile(urlOrUrls, outputPath) {
return new Promise((resolve, reject) => {
const candidates = Array.isArray(urlOrUrls)
? Array.from(new Set(urlOrUrls.flatMap((url) => normalizeRuleSetUrl(url))))
: normalizeRuleSetUrl(urlOrUrls);
let index = 0;
let lastError = "";
function tryNext() {
const url = candidates[index];
if (!url) {
reject(new Error(lastError || "Не удалось скачать файл"));
return;
}
let stderr = "";
const dl = spawn("curl", [
"-fsSL",
"--retry",
"2",
"--retry-delay",
"1",
"--connect-timeout",
"10",
"--max-time",
"45",
"-A",
"vpn-proxy-app",
url,
"-o",
outputPath,
]);
dl.stderr.on("data", (d) => {
stderr += d;
});
dl.on("error", (err) => {
lastError = err.message;
index += 1;
tryNext();
});
dl.on("close", (code) => {
if (code === 0) {
resolve(url);
return;
}
lastError = `curl ${url} завершился с кодом ${code}: ${stderr}`;
index += 1;
tryNext();
});
}
tryNext();
});
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
if (!chunks.length) return resolve({});
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
} catch {
reject(new Error("Невалидный JSON в теле запроса"));
}
});
req.on("error", reject);
});
}
function checkSingboxConfig() {
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(),
);
}
}
function stopSingbox() {
return new Promise((resolve) => {
if (!singboxProcess) {
singboxStartedAt = null;
return resolve();
}
const current = singboxProcess;
singboxProcess = null;
singboxStartedAt = null;
const timeout = setTimeout(() => {
current.kill("SIGKILL");
resolve();
}, 4000);
current.once("exit", () => {
clearTimeout(timeout);
resolve();
});
current.kill("SIGTERM");
});
}
async function startSingbox() {
if (!fs.existsSync(settings.configPath)) return false;
checkSingboxConfig();
await stopSingbox();
singboxProcess = spawn("sing-box", ["run", "-c", settings.configPath], {
stdio: ["ignore", "pipe", "pipe"],
});
singboxStartedAt = new Date().toISOString();
saveSingboxPid(singboxProcess.pid);
pushLog("info", `sing-box запущен (pid=${singboxProcess.pid})`);
captureStream(singboxProcess.stdout, "info");
captureStream(singboxProcess.stderr, "error");
singboxProcess.once("exit", (code, signal) => {
pushLog("info", `sing-box завершён: code=${code} signal=${signal}`);
singboxProcess = null;
singboxStartedAt = null;
removeSingboxPid();
});
return true;
}
function publicState() {
const state = readJson(settings.statePath, {});
const customRules = readJson(settings.customRulesPath, []);
const deviceProfiles = readDeviceProfiles();
const { subscriptionUrl, ...rest } = state;
return {
mode: settings.appMode,
port: settings.port,
proxyPort: settings.proxyPort,
proxyBindIp: settings.bindIp,
tproxyPort: settings.appMode === "gateway" ? settings.tproxyPort : null,
routingRuDirect: settings.routingRuDirect,
configExists: fs.existsSync(settings.configPath),
singboxRunning: Boolean(singboxProcess),
singboxStartedAt,
subscriptionHost: maskSubscriptionUrl(subscriptionUrl),
hasSubscription: Boolean(subscriptionUrl),
customRules,
devicesConfig: deviceProfiles,
devices: deviceProfiles.devices,
deviceRules: legacyDeviceRulesFromProfiles(deviceProfiles),
appliedHistory: state.appliedHistory || [],
rulesUpdatedAt: state.rulesUpdatedAt || null,
devicesUpdatedAt: state.devicesUpdatedAt || null,
rulesAppliedAt: state.rulesAppliedAt || null,
bypassMode: Boolean(state.bypassMode),
directBypassCount,
directBypassEnabled: DIRECT_BYPASS_CACHE,
directBypassAvailable: IPSET_AVAILABLE,
...rest,
};
}
function normalizeList(value) {
if (Array.isArray(value)) {
return value.map((item) => String(item || "").trim()).filter(Boolean);
}
return String(value || "")
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter(Boolean);
}
function normalizeCustomRules(input) {
const rules = Array.isArray(input) ? input : [];
return rules.map((rule, index) => ({
id: String(rule.id || `rule-${Date.now()}-${index}`),
name: String(rule.name || `Правило ${index + 1}`).trim(),
enabled: rule.enabled !== false,
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),
),
ruleSets: normalizeList(rule.ruleSets).filter((tag) =>
RULE_SET_TAG_RE.test(tag),
),
}));
}
function normalizeDeviceRules(input) {
const rules = Array.isArray(input) ? input : [];
return rules.map((r, index) => ({
id: String(r.id || `dev-${Date.now()}-${index}`),
name: String(r.name || `Устройство ${index + 1}`).trim(),
enabled: r.enabled !== false,
sourceIps: normalizeList(r.sourceIps).filter((ip) =>
/^[\.\d:/]+$/.test(ip),
),
outbound: ["direct", "vpn", "block"].includes(r.outbound)
? r.outbound
: "direct",
}));
}
async function applySelectedServer(selectedTag) {
const cached = readJson(settings.subscriptionCachePath, null);
if (!cached?.config) {
throw new Error("Сначала загрузите подписку");
}
const customRules = readJson(settings.customRulesPath, []);
const stateForBypass = readJson(settings.statePath, {});
const generated = buildGatewayConfig(
{ ...cached.config, customRules },
selectedTag,
{ bypassAll: Boolean(stateForBypass.bypassMode) },
);
writeSingboxConfig(generated);
await startSingbox();
const prevState = readJson(settings.statePath, {});
const now = new Date().toISOString();
const previousTag =
prevState.selectedTag && prevState.selectedTag !== selectedTag
? prevState.selectedTag
: prevState.previousTag || null;
const history = Array.isArray(prevState.appliedHistory)
? prevState.appliedHistory
: [];
const nextHistory = [
{ tag: selectedTag, at: now },
...history.filter((h) => h.tag !== selectedTag),
].slice(0, APPLY_HISTORY_LIMIT);
writeJson(settings.statePath, {
...prevState,
selectedTag,
previousTag,
appliedAt: now,
rulesAppliedAt: now,
appliedHistory: nextHistory,
});
}
function handleLogsStream(req, res) {
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
"x-accel-buffering": "no",
});
for (const entry of logBuffer.slice(-200)) {
res.write(`data: ${JSON.stringify(entry)}\n\n`);
}
const subscriber = (entry) => {
res.write(`data: ${JSON.stringify(entry)}\n\n`);
};
logSubscribers.add(subscriber);
const keepalive = setInterval(() => {
try {
res.write(": ping\n\n");
} catch {}
}, 15000);
req.on("close", () => {
clearInterval(keepalive);
logSubscribers.delete(subscriber);
});
}
async function handleApi(req, res) {
if (req.method === "GET" && req.url === "/api/state") {
return sendJson(res, 200, publicState());
}
if (req.method === "GET" && req.url === "/api/config") {
const config = readSingboxConfig();
return sendJson(res, 200, { success: true, config });
}
if (req.method === "GET" && req.url === "/api/logs") {
return sendJson(res, 200, { success: true, logs: logBuffer.slice(-200) });
}
if (req.method === "GET" && req.url === "/api/logs/stream") {
return handleLogsStream(req, res);
}
if (req.method === "GET" && req.url === "/api/traffic/stream") {
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
"x-accel-buffering": "no",
});
for (const entry of trafficBuffer.slice(-200)) {
res.write(`data: ${JSON.stringify(entry)}\n\n`);
}
const sub = (entry) => res.write(`data: ${JSON.stringify(entry)}\n\n`);
trafficSubscribers.add(sub);
const keepalive = setInterval(() => {
try {
res.write(": ping\n\n");
} catch {}
}, 15000);
req.on("close", () => {
clearInterval(keepalive);
trafficSubscribers.delete(sub);
});
return;
}
if (req.method === "DELETE" && req.url === "/api/traffic") {
trafficBuffer.splice(0);
return sendJson(res, 200, { success: true });
}
if (req.method === "GET" && req.url === "/api/direct-cache") {
const members = listDirectBypass();
return sendJson(res, 200, {
success: true,
count: members.length,
available: IPSET_AVAILABLE,
members,
});
}
if (req.method === "DELETE" && req.url === "/api/direct-cache") {
flushDirectBypass();
return sendJson(res, 200, { success: true });
}
if (req.method === "POST" && req.url === "/api/bypass") {
const body = await readBody(req);
const enabled = Boolean(body.enabled);
const prevState = readJson(settings.statePath, {});
writeJson(settings.statePath, { ...prevState, bypassMode: enabled });
// Перегенерируем и применяем конфиг, если sing-box запущен
if (singboxProcess && prevState.selectedTag) {
const cached = readJson(settings.subscriptionCachePath, null);
if (cached?.config) {
const customRules = readJson(settings.customRulesPath, []);
const generated = buildGatewayConfig(
{ ...cached.config, customRules },
prevState.selectedTag,
{ bypassAll: enabled },
);
writeSingboxConfig(generated);
await startSingbox();
pushLog(
"info",
enabled
? "Режим обхода включён — весь трафик идёт напрямую"
: "Режим обхода отключён — правила маршрутизации восстановлены",
);
}
}
return sendJson(res, 200, { success: true, bypassMode: enabled });
}
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") {
const body = await readBody(req);
const rules = normalizeCustomRules(body.rules);
writeJson(settings.customRulesPath, rules);
const prevState = readJson(settings.statePath, {});
writeJson(settings.statePath, {
...prevState,
rulesUpdatedAt: new Date().toISOString(),
});
return sendJson(res, 200, { success: true, rules });
}
if (req.method === "GET" && req.url === "/api/rules/conflicts") {
const rules = readJson(settings.customRulesPath, []);
return sendJson(res, 200, {
success: true,
conflicts: detectRuleConflicts(rules),
});
}
if (req.method === "GET" && req.url === "/api/device-rules") {
const deviceProfiles = readDeviceProfiles();
return sendJson(res, 200, {
success: true,
deviceRules: legacyDeviceRulesFromProfiles(deviceProfiles),
});
}
if (req.method === "PUT" && req.url === "/api/device-rules") {
const body = await readBody(req);
const rules = normalizeDeviceRules(body.deviceRules);
const devices = [];
for (const rule of rules) {
rule.sourceIps.forEach((ip, index) => {
devices.push({
id: `${rule.id}-${index}`,
name: rule.name,
enabled: rule.enabled,
ip,
mode: rule.outbound,
});
});
}
const profiles = writeDeviceProfiles({
defaultTransparentMode: "vpn",
proxyDefaultMode: "vpn",
devices,
});
const prevState = readJson(settings.statePath, {});
writeJson(settings.statePath, {
...prevState,
devicesUpdatedAt: new Date().toISOString(),
});
return sendJson(res, 200, {
success: true,
...profiles,
deviceRules: legacyDeviceRulesFromProfiles(profiles),
});
}
if (req.method === "GET" && req.url === "/api/devices") {
const profiles = readDeviceProfiles();
return sendJson(res, 200, { success: true, ...profiles });
}
if (req.method === "PUT" && req.url === "/api/devices") {
const body = await readBody(req);
const input = body.devicesConfig || {
defaultTransparentMode: body.defaultTransparentMode || body.defaultMode,
proxyDefaultMode: body.proxyDefaultMode,
devices: body.devices,
};
const profiles = writeDeviceProfiles(input);
const prevState = readJson(settings.statePath, {});
const devicesUpdatedAt = new Date().toISOString();
writeJson(settings.statePath, {
...prevState,
devicesUpdatedAt,
});
return sendJson(res, 200, {
success: true,
...profiles,
devicesUpdatedAt,
});
}
if (req.method === "GET" && req.url === "/api/rule-sets") {
return sendJson(res, 200, {
success: true,
ruleSets: readJson(settings.customRuleSetsPath, []),
});
}
if (req.method === "PUT" && req.url === "/api/rule-sets") {
const body = await readBody(req);
const rawSets = Array.isArray(body.ruleSets) ? body.ruleSets : [];
const normalized = rawSets
.filter((rs) => rs && rs.tag && rs.url)
.map((rs) => ({
tag: String(rs.tag).trim(),
url: String(rs.url).trim(),
format: rs.format === "source" ? "source" : "binary",
}))
.filter((rs) => RULE_SET_TAG_RE.test(rs.tag));
writeJson(settings.customRuleSetsPath, normalized);
return sendJson(res, 200, { success: true, ruleSets: normalized });
}
if (req.method === "POST" && req.url === "/api/rule-sets/lookup") {
const body = await readBody(req);
const url = String(body.url || "").trim();
const tag = String(body.tag || "").trim();
if (!url)
return sendJson(res, 400, { success: false, error: "Укажите url" });
// Кеш — файл рядом с custom-rule-sets.json
// Используем crypto hash чтобы избежать коллизий при одинаковом префиксе URL
const cacheKey = crypto.createHash("sha1").update(url).digest("hex");
const cacheFile = path.join(
settings.dataDir,
`ruleset-cache-${cacheKey}.json`,
);
const CACHE_TTL_MS = 3 * 60 * 60 * 1000; // 3 часа
if (fs.existsSync(cacheFile)) {
try {
const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
if (Date.now() - new Date(cached.cachedAt).getTime() < CACHE_TTL_MS) {
return sendJson(res, 200, { success: true, ...cached });
}
} catch {}
}
// Скачать .srs во временный файл
const tmpSrs = path.join(os.tmpdir(), `singbox-rs-${Date.now()}.srs`);
const tmpJson = tmpSrs.replace(".srs", ".json");
try {
const downloadedFrom = await downloadFile(url, tmpSrs);
// Декомпилировать через sing-box rule-set decompile
const dec = spawnSync(
"sing-box",
["rule-set", "decompile", "--output", tmpJson, tmpSrs],
{
timeout: 15000,
encoding: "utf8",
},
);
if (dec.error) {
return sendJson(res, 200, {
success: false,
error: `sing-box не найден или не запустился: ${dec.error.message}`,
});
}
if (dec.status !== 0) {
return sendJson(res, 200, {
success: false,
error: `sing-box decompile завершился с ошибкой: ${dec.stderr || "неизвестная ошибка"}`,
});
}
const raw = JSON.parse(fs.readFileSync(tmpJson, "utf8"));
// Плоский список записей из всех rules
const rules = Array.isArray(raw.rules) ? raw.rules : [];
const entries = [];
for (const rule of rules) {
if (Array.isArray(rule.domain))
entries.push(
...rule.domain.map((v) => ({ type: "domain", value: v })),
);
if (Array.isArray(rule.domain_suffix))
entries.push(
...rule.domain_suffix.map((v) => ({ type: "suffix", value: v })),
);
if (Array.isArray(rule.domain_keyword))
entries.push(
...rule.domain_keyword.map((v) => ({ type: "keyword", value: v })),
);
if (Array.isArray(rule.ip_cidr))
entries.push(
...rule.ip_cidr.map((v) => ({ type: "cidr", value: v })),
);
if (Array.isArray(rule.domain_regex))
entries.push(
...rule.domain_regex.map((v) => ({ type: "regex", value: v })),
);
}
const stats = {
domain: entries.filter((e) => e.type === "domain").length,
suffix: entries.filter((e) => e.type === "suffix").length,
keyword: entries.filter((e) => e.type === "keyword").length,
cidr: entries.filter((e) => e.type === "cidr").length,
regex: entries.filter((e) => e.type === "regex").length,
total: entries.length,
};
const result = {
tag,
url,
downloadedFrom,
entries,
stats,
cachedAt: new Date().toISOString(),
};
writeJson(cacheFile, result);
return sendJson(res, 200, { success: true, ...result });
} catch (err) {
return sendJson(res, 200, { success: false, error: err.message });
} finally {
for (const f of [tmpSrs, tmpJson]) {
try {
fs.unlinkSync(f);
} catch {}
}
}
}
if (req.method === "GET" && req.url === "/api/rule-sets/sagernet-catalog") {
const cacheFile = path.join(
settings.dataDir,
"sagernet-catalog-cache.json",
);
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 часа
if (fs.existsSync(cacheFile)) {
try {
const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
if (Date.now() - new Date(cached.cachedAt).getTime() < CACHE_TTL_MS) {
return sendJson(res, 200, { success: true, ...cached });
}
} catch {}
}
try {
const headers = { "User-Agent": "vpn-proxy-app" };
const [gsRes, giRes] = await Promise.all([
fetch(
"https://api.github.com/repos/SagerNet/sing-geosite/git/trees/rule-set?recursive=1",
{ headers },
),
fetch(
"https://api.github.com/repos/SagerNet/sing-geoip/git/trees/rule-set?recursive=1",
{ headers },
),
]);
if (!gsRes.ok || !giRes.ok) {
throw new Error(
`GitHub API недоступен: geosite=${gsRes.status}, geoip=${giRes.status}`,
);
}
const gsData = await gsRes.json();
const giData = await giRes.json();
const geosite = (gsData.tree || [])
.filter((f) => f.path.endsWith(".srs"))
.map((f) => f.path.replace(".srs", ""))
.sort();
const geoip = (giData.tree || [])
.filter((f) => f.path.endsWith(".srs"))
.map((f) => f.path.replace(".srs", ""))
.sort();
if (!geosite.length && !geoip.length) {
throw new Error("GitHub API вернул пустой каталог rule-set");
}
const result = { geosite, geoip, cachedAt: new Date().toISOString() };
writeJson(cacheFile, result);
return sendJson(res, 200, { success: true, ...result });
} catch (err) {
const result = {
...FALLBACK_RULE_SET_CATALOG,
cachedAt: new Date().toISOString(),
fallback: true,
warning: `GitHub каталог не загрузился, показан встроенный список: ${err.message}`,
};
return sendJson(res, 200, { success: true, ...result });
}
}
if (req.method === "POST" && req.url === "/api/route/check") {
const body = await readBody(req);
const host = String(body.host || "").trim();
let ip = String(body.ip || "").trim();
const port =
body.port !== undefined && body.port !== ""
? Number(body.port)
: undefined;
const network = String(body.network || "").trim() || undefined;
const sourceIp = String(body.sourceIp || "").trim() || undefined;
const inbound = String(body.inbound || "").trim() || undefined;
if (!host && !ip) {
return sendJson(res, 400, {
success: false,
error: "Укажите домен или IP",
});
}
let resolvedFrom = null;
if (!ip && host) {
const resolved = await resolveHost(host);
if (resolved) {
ip = resolved;
resolvedFrom = host;
}
}
const rules = readJson(settings.customRulesPath, []);
const state = readJson(settings.statePath, {});
const vpnTag = state.selectedTag || "vpn-out";
const result = matchRoute({ host, ip, port, network, sourceIp, inbound }, rules, {
routingRuDirect: settings.routingRuDirect,
vpnTag,
deviceProfiles: readDeviceProfiles(),
});
return sendJson(res, 200, {
success: true,
result,
resolvedIp: ip || null,
resolvedFrom,
});
}
if (req.method === "POST" && req.url === "/api/servers/ping") {
const body = await readBody(req);
const host = String(body.host || "").trim();
const port = Number(body.port);
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
return sendJson(res, 400, {
success: false,
error: "Требуются host и port",
});
}
const result = await tcpPing(host, port, Number(body.timeout) || 3000);
return sendJson(res, 200, { success: true, ...result });
}
if (req.method === "POST" && req.url === "/api/servers/ping-all") {
const cached = readJson(settings.subscriptionCachePath, null);
const state = readJson(settings.statePath, {});
const servers = state.servers || cached?.servers || [];
const results = await Promise.all(
servers.map(async (server) => {
const ping = await tcpPing(server.server, server.server_port, 3000);
return {
tag: server.tag,
...ping,
checkedAt: new Date().toISOString(),
};
}),
);
return sendJson(res, 200, { success: true, results });
}
if (req.method === "POST" && req.url === "/api/config/validate") {
const cached = readJson(settings.subscriptionCachePath, null);
const customRules = readJson(settings.customRulesPath, []);
const stateData = readJson(settings.statePath, {});
const tag = stateData.selectedTag;
if (!cached?.config) {
return sendJson(res, 200, {
success: true,
valid: false,
error: "Подписка не загружена",
});
}
if (!tag) {
return sendJson(res, 200, {
success: true,
valid: false,
error: "Сервер не выбран",
});
}
try {
buildGatewayConfig({ ...cached.config, customRules }, tag);
} catch (err) {
return sendJson(res, 200, {
success: true,
valid: false,
error: err.message,
});
}
if (fs.existsSync(settings.configPath)) {
try {
checkSingboxConfig();
return sendJson(res, 200, { success: true, valid: true });
} catch (err) {
return sendJson(res, 200, {
success: true,
valid: false,
error: err.message,
});
}
}
return sendJson(res, 200, {
success: true,
valid: true,
note: "Конфиг собирается без ошибок (sing-box check не выполнен — нет файла)",
});
}
if (req.method === "POST" && req.url === "/api/apply/rollback") {
const stateData = readJson(settings.statePath, {});
const target = stateData.previousTag;
if (!target) {
return sendJson(res, 400, {
success: false,
error: "Нет предыдущего сервера для отката",
});
}
await applySelectedServer(target);
return sendJson(res, 200, { success: true, selectedTag: target });
}
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 parsed = await fetchSubscription(url);
writeJson(settings.subscriptionCachePath, { url, ...parsed });
const prevState = readJson(settings.statePath, {});
writeJson(settings.statePath, {
...prevState,
subscriptionUrl: url,
servers: parsed.servers,
userInfo: parsed.userInfo,
fetchedAt: parsed.fetchedAt,
});
return sendJson(res, 200, { success: true, ...parsed });
}
if (req.method === "DELETE" && req.url === "/api/subscription") {
if (fs.existsSync(settings.subscriptionCachePath))
fs.rmSync(settings.subscriptionCachePath);
const prevState = readJson(settings.statePath, {});
delete prevState.subscriptionUrl;
delete prevState.servers;
delete prevState.userInfo;
delete prevState.fetchedAt;
delete prevState.selectedTag;
delete prevState.appliedAt;
writeJson(settings.statePath, prevState);
await stopSingbox();
removeSingboxConfig();
pushLog("info", "Подписка удалена, sing-box остановлен");
return sendJson(res, 200, { success: true });
}
if (req.method === "POST" && req.url === "/api/apply") {
const body = await readBody(req);
const selectedTag = String(body.selectedTag || "").trim();
if (!selectedTag)
return sendJson(res, 400, {
success: false,
error: "selectedTag обязателен",
});
await applySelectedServer(selectedTag);
return sendJson(res, 200, {
success: true,
selectedTag,
configPath: settings.configPath,
singboxRunning: Boolean(singboxProcess),
});
}
if (req.method === "POST" && req.url === "/api/singbox/stop") {
await stopSingbox();
pushLog("info", "sing-box остановлен пользователем");
return sendJson(res, 200, { success: true, singboxRunning: false });
}
if (req.method === "POST" && req.url === "/api/singbox/restart") {
if (!fs.existsSync(settings.configPath)) {
return sendJson(res, 400, {
success: false,
error: "Конфиг отсутствует — сначала примените сервер",
});
}
await startSingbox();
pushLog("info", "sing-box перезапущен пользователем");
return sendJson(res, 200, {
success: true,
singboxRunning: Boolean(singboxProcess),
});
}
if (req.method === "POST" && req.url === "/api/singbox/clear") {
await stopSingbox();
removeSingboxConfig();
const prevState = readJson(settings.statePath, {});
delete prevState.selectedTag;
delete prevState.appliedAt;
writeJson(settings.statePath, prevState);
pushLog("info", "Конфиг sing-box удалён, процесс остановлен");
return sendJson(res, 200, { success: true, singboxRunning: false });
}
return sendJson(res, 404, { success: false, error: "Не найдено" });
}
const mime = {
".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 filePath = path.resolve(settings.distDir, `.${cleanPath}`);
const distRoot = path.resolve(settings.distDir);
if (!filePath.startsWith(distRoot)) {
res.writeHead(403);
return res.end("Forbidden");
}
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",
});
fs.createReadStream(finalPath).pipe(res);
}
const server = http.createServer(async (req, res) => {
try {
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),
});
}
});
process.on("SIGTERM", async () => {
await stopSingbox();
process.exit(0);
});
process.on("SIGINT", async () => {
await stopSingbox();
process.exit(0);
});
// При старте пробуем подхватить уже запущенный sing-box
const existingPid = readSingboxPid();
if (existingPid && isPidAlive(existingPid)) {
attachExistingSingbox(existingPid);
} else {
removeSingboxPid();
// Если конфиг отсутствует (например после передплоя), пробуем пересобрать из кэша
if (!fs.existsSync(settings.configPath)) {
const stateData = readJson(settings.statePath, {});
const cached = readJson(settings.subscriptionCachePath, null);
if (stateData.selectedTag && cached?.config) {
try {
const customRules = readJson(settings.customRulesPath, []);
const generated = buildGatewayConfig(
{ ...cached.config, customRules },
stateData.selectedTag,
{ bypassAll: Boolean(stateData.bypassMode) },
);
writeSingboxConfig(generated);
pushLog(
"info",
`Конфиг sing-box восстановлен из кэша (сервер: ${stateData.selectedTag})`,
);
} catch (err) {
pushLog(
"error",
`Не удалось восстановить конфиг sing-box: ${err.message}`,
);
}
}
}
await startSingbox().catch((error) => {
console.warn(`[control] sing-box не запущен: ${error.message}`);
pushLog("error", `sing-box не запущен при старте: ${error.message}`);
});
}
server.listen(settings.port, "0.0.0.0", () => {
console.log(`[control] gateway UI слушает :${settings.port}`);
});