feat: добавлены новые компоненты для управления правилами и серверами
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 25s
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 25s
- Создан компонент RuleEditorDrawer для редактирования правил с поддержкой JSON. - Добавлен компонент ServersPage для отображения и управления серверами. - Реализован компонент SettingsPage для управления подписками и конфигурациями. - Создан компонент Sidebar для навигации по приложению. - Добавлен компонент StatusPane для отображения статуса сервера. - Реализован компонент Toasts для отображения уведомлений. - Создан компонент Topbar для отображения информации о текущем состоянии. - Добавлен модуль country.js для определения страны по тегу сервера. Refs: None
This commit is contained in:
@@ -9,7 +9,8 @@ export const settings = {
|
||||
bindIp: process.env.PROXY_BIND_IP || "127.0.0.1",
|
||||
dataDir,
|
||||
distDir: process.env.DIST_DIR || "/app/dist",
|
||||
configPath: process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
|
||||
configPath:
|
||||
process.env.SING_BOX_CONFIG || path.join(dataDir, "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"),
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
readSingboxConfig,
|
||||
removeSingboxConfig,
|
||||
} from "./singbox.js";
|
||||
import { matchRoute, detectRuleConflicts } from "./routeMatcher.js";
|
||||
import { tcpPing, resolveHost } from "./ping.js";
|
||||
|
||||
const APPLY_HISTORY_LIMIT = 10;
|
||||
|
||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||
|
||||
@@ -32,7 +36,8 @@ function pushLog(level, line) {
|
||||
|
||||
// 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;
|
||||
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;
|
||||
@@ -192,6 +197,9 @@ function publicState() {
|
||||
subscriptionHost: maskSubscriptionUrl(subscriptionUrl),
|
||||
hasSubscription: Boolean(subscriptionUrl),
|
||||
customRules,
|
||||
appliedHistory: state.appliedHistory || [],
|
||||
rulesUpdatedAt: state.rulesUpdatedAt || null,
|
||||
rulesAppliedAt: state.rulesAppliedAt || null,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
@@ -241,10 +249,18 @@ async function applySelectedServer(selectedTag) {
|
||||
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,
|
||||
appliedAt: new Date().toISOString(),
|
||||
previousTag,
|
||||
appliedAt: now,
|
||||
rulesAppliedAt: now,
|
||||
appliedHistory: nextHistory,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -306,9 +322,116 @@ async function handleApi(req, res) {
|
||||
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 === "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;
|
||||
|
||||
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 cached = readJson(settings.subscriptionCachePath, null);
|
||||
const state = readJson(settings.statePath, {});
|
||||
const vpnTag = state.selectedTag || "vpn-out";
|
||||
const result = matchRoute(
|
||||
{ host, ip, port, network },
|
||||
rules,
|
||||
{ routingRuDirect: settings.routingRuDirect, vpnTag },
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
44
src/server/ping.js
Normal file
44
src/server/ping.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// TCP-пинг: меряем время до открытия TCP-соединения с хостом:портом.
|
||||
// Это не ICMP-ping, но для VPN-серверов точнее (проверяем именно тот порт, куда подключается клиент).
|
||||
|
||||
import net from "node:net";
|
||||
import dns from "node:dns/promises";
|
||||
|
||||
const DEFAULT_TIMEOUT = 3000;
|
||||
|
||||
export async function tcpPing(host, port, timeout = DEFAULT_TIMEOUT) {
|
||||
const start = Date.now();
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
let done = false;
|
||||
|
||||
const finish = (result) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
socket.setTimeout(timeout);
|
||||
socket.once("connect", () => finish({ ok: true, latency: Date.now() - start }));
|
||||
socket.once("timeout", () => finish({ ok: false, latency: null, error: "timeout" }));
|
||||
socket.once("error", (err) => finish({ ok: false, latency: null, error: err.code || err.message }));
|
||||
|
||||
try {
|
||||
socket.connect(port, host);
|
||||
} catch (err) {
|
||||
finish({ ok: false, latency: null, error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveHost(host) {
|
||||
if (net.isIP(host)) return host;
|
||||
try {
|
||||
const result = await dns.lookup(host);
|
||||
return result.address;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
222
src/server/routeMatcher.js
Normal file
222
src/server/routeMatcher.js
Normal file
@@ -0,0 +1,222 @@
|
||||
// Простой симулятор роутинга sing-box.
|
||||
// Берём список customRules + safety/RU-direct и определяем, какое правило сработает.
|
||||
// Для geoip-ru / geosite-category-ru возвращаем "может сработать" — без скачанного ruleset
|
||||
// мы не можем точно сказать, попадает ли IP/домен в RU.
|
||||
|
||||
import net from "node:net";
|
||||
|
||||
function ipv4ToInt(ip) {
|
||||
const parts = ip.split(".").map((x) => Number.parseInt(x, 10));
|
||||
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return null;
|
||||
return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
|
||||
}
|
||||
|
||||
function ipInCidr(ip, cidr) {
|
||||
if (!net.isIP(ip)) return false;
|
||||
const [addr, maskStr] = String(cidr).split("/");
|
||||
if (!addr) return false;
|
||||
|
||||
if (net.isIPv4(ip) && net.isIPv4(addr)) {
|
||||
const mask = maskStr === undefined ? 32 : Number.parseInt(maskStr, 10);
|
||||
if (!Number.isInteger(mask) || mask < 0 || mask > 32) return false;
|
||||
const ipInt = ipv4ToInt(ip);
|
||||
const cidrInt = ipv4ToInt(addr);
|
||||
if (ipInt === null || cidrInt === null) return false;
|
||||
if (mask === 0) return true;
|
||||
const m = (~0 << (32 - mask)) >>> 0;
|
||||
return (ipInt & m) === (cidrInt & m);
|
||||
}
|
||||
// IPv6 — упрощённо: точное сравнение строк (без полноценной обработки)
|
||||
return false;
|
||||
}
|
||||
|
||||
const PRIVATE_CIDRS = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "169.254.0.0/16"];
|
||||
|
||||
function isPrivateIp(ip) {
|
||||
if (!ip) return false;
|
||||
return PRIVATE_CIDRS.some((cidr) => ipInCidr(ip, cidr));
|
||||
}
|
||||
|
||||
function hostMatchesDomain(host, domain) {
|
||||
if (!host || !domain) return false;
|
||||
return host.toLowerCase() === domain.toLowerCase();
|
||||
}
|
||||
|
||||
function hostMatchesSuffix(host, suffix) {
|
||||
if (!host || !suffix) return false;
|
||||
const h = host.toLowerCase();
|
||||
const s = suffix.toLowerCase();
|
||||
return h === s || h.endsWith("." + s) || h.endsWith(s);
|
||||
}
|
||||
|
||||
function hostMatchesKeyword(host, keyword) {
|
||||
if (!host || !keyword) return false;
|
||||
return host.toLowerCase().includes(keyword.toLowerCase());
|
||||
}
|
||||
|
||||
function ruleMatches(rule, target) {
|
||||
const { host = "", ip = "", port, network } = target;
|
||||
|
||||
if (!rule?.enabled) return false;
|
||||
|
||||
const checks = [];
|
||||
|
||||
if (rule.domains?.length) {
|
||||
checks.push(rule.domains.some((d) => hostMatchesDomain(host, d)));
|
||||
}
|
||||
if (rule.domainSuffixes?.length) {
|
||||
checks.push(rule.domainSuffixes.some((d) => hostMatchesSuffix(host, d)));
|
||||
}
|
||||
if (rule.domainKeywords?.length) {
|
||||
checks.push(rule.domainKeywords.some((d) => hostMatchesKeyword(host, d)));
|
||||
}
|
||||
if (rule.ipCidrs?.length) {
|
||||
if (!ip) return false;
|
||||
checks.push(rule.ipCidrs.some((cidr) => ipInCidr(ip, cidr)));
|
||||
}
|
||||
if (rule.ports?.length) {
|
||||
if (port === undefined || port === null || port === "") return false;
|
||||
const p = Number(port);
|
||||
checks.push(
|
||||
rule.ports.some((portStr) => {
|
||||
const s = String(portStr).trim();
|
||||
if (s.includes("-")) {
|
||||
const [from, to] = s.split("-").map((x) => Number(x));
|
||||
return p >= from && p <= to;
|
||||
}
|
||||
return p === Number(s);
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (rule.networks?.length) {
|
||||
if (!network) return false;
|
||||
checks.push(rule.networks.includes(network));
|
||||
}
|
||||
|
||||
if (!checks.length) return false;
|
||||
return checks.every(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Симулирует роутинг и возвращает результат.
|
||||
* @param {object} target { host, ip, port, network }
|
||||
* @param {Array} customRules
|
||||
* @param {object} options { routingRuDirect, vpnTag }
|
||||
*/
|
||||
export function matchRoute(target, customRules, options = {}) {
|
||||
const { routingRuDirect = true, vpnTag = "vpn-out" } = options;
|
||||
const rules = Array.isArray(customRules) ? customRules : [];
|
||||
|
||||
// 1. private IP → direct
|
||||
if (target.ip && isPrivateIp(target.ip)) {
|
||||
return {
|
||||
matched: "system",
|
||||
ruleIndex: -1,
|
||||
ruleName: "private IP → direct",
|
||||
outbound: "direct",
|
||||
reason: `IP ${target.ip} приватный`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. custom rules (first match wins)
|
||||
for (let i = 0; i < rules.length; i += 1) {
|
||||
const rule = rules[i];
|
||||
if (ruleMatches(rule, target)) {
|
||||
const outbound = rule.outbound === "vpn" ? `${vpnTag} (VPN)` : rule.outbound;
|
||||
return {
|
||||
matched: "custom",
|
||||
ruleIndex: i,
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
outbound,
|
||||
reason: "Совпадение по custom-правилу",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. RU direct (geoip/geosite) — мы не знаем точно, скажем "может сработать"
|
||||
if (routingRuDirect) {
|
||||
return {
|
||||
matched: "fallback-ru-or-vpn",
|
||||
ruleIndex: -2,
|
||||
ruleName: "geoip-ru / geosite-category-ru → direct, иначе VPN",
|
||||
outbound: `direct или ${vpnTag}`,
|
||||
reason:
|
||||
"Если домен/IP попадает в geoip-ru или geosite-category-ru — direct; иначе — VPN. Без локальной базы точно не определить.",
|
||||
};
|
||||
}
|
||||
|
||||
// 4. final → VPN
|
||||
return {
|
||||
matched: "final",
|
||||
ruleIndex: -3,
|
||||
ruleName: "final",
|
||||
outbound: vpnTag,
|
||||
reason: "Не сработало ни одно правило — пойдёт через VPN",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Детектор конфликтов: ищет правила, перекрытые предыдущими.
|
||||
* Простая эвристика: если правило-кандидат полностью перекрывается ранее идущим
|
||||
* по доменам/суффиксам/CIDR — отмечаем конфликт.
|
||||
*/
|
||||
export function detectRuleConflicts(rules) {
|
||||
const list = Array.isArray(rules) ? rules : [];
|
||||
const conflicts = [];
|
||||
|
||||
for (let i = 1; i < list.length; i += 1) {
|
||||
const cur = list[i];
|
||||
if (!cur?.enabled) continue;
|
||||
|
||||
for (let j = 0; j < i; j += 1) {
|
||||
const prev = list[j];
|
||||
if (!prev?.enabled) continue;
|
||||
|
||||
// Если outbound одинаковый — это не "конфликт", это дубликат
|
||||
const sameOutbound = prev.outbound === cur.outbound;
|
||||
|
||||
// Проверка перекрытия доменов
|
||||
const overlaps = [];
|
||||
|
||||
// Точные домены покрываются prev.suffix
|
||||
for (const d of cur.domains || []) {
|
||||
if ((prev.domainSuffixes || []).some((s) => hostMatchesSuffix(d, s))) {
|
||||
overlaps.push({ kind: "domain", value: d, by: `суффикс ${(prev.domainSuffixes || []).find((s) => hostMatchesSuffix(d, s))}` });
|
||||
}
|
||||
if ((prev.domains || []).includes(d)) {
|
||||
overlaps.push({ kind: "domain", value: d, by: "точный домен" });
|
||||
}
|
||||
}
|
||||
|
||||
// Суффиксы покрываются более общим суффиксом prev
|
||||
for (const s of cur.domainSuffixes || []) {
|
||||
if ((prev.domainSuffixes || []).some((ps) => hostMatchesSuffix(s, ps) && ps !== s)) {
|
||||
overlaps.push({ kind: "suffix", value: s, by: "более общий суффикс" });
|
||||
}
|
||||
}
|
||||
|
||||
// CIDR
|
||||
for (const c of cur.ipCidrs || []) {
|
||||
if ((prev.ipCidrs || []).includes(c)) {
|
||||
overlaps.push({ kind: "cidr", value: c, by: "тот же CIDR" });
|
||||
}
|
||||
}
|
||||
|
||||
if (overlaps.length) {
|
||||
conflicts.push({
|
||||
ruleId: cur.id,
|
||||
ruleIndex: i,
|
||||
ruleName: cur.name,
|
||||
conflictWithId: prev.id,
|
||||
conflictWithIndex: j,
|
||||
conflictWithName: prev.name,
|
||||
severity: sameOutbound ? "info" : "warning",
|
||||
overlaps,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
Reference in New Issue
Block a user