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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user