feat: добавлена возможность поиска и декомпиляции rule-sets
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 2s

Refs: None
This commit is contained in:
2026-05-08 20:15:33 +03:00
parent b1c8eea976
commit 7d1f5f89ed
3 changed files with 519 additions and 62 deletions

View File

@@ -4,6 +4,7 @@ import path from "node:path";
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,
@@ -449,6 +450,128 @@ async function handleApi(req, res) {
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
const cacheKey = Buffer.from(url).toString("base64url").slice(0, 48);
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 {
await new Promise((resolve, reject) => {
let stderr = "";
// Скачиваем через curl (доступен в контейнере)
const dl = spawn("curl", [
"-fsSL",
"--max-time",
"30",
url,
"-o",
tmpSrs,
]);
dl.stderr.on("data", (d) => {
stderr += d;
});
dl.on("close", (code) => {
if (code !== 0)
reject(new Error(`curl завершился с кодом ${code}: ${stderr}`));
else resolve();
});
});
// Декомпилировать через sing-box rule-set decompile
const dec = spawnSync(
"sing-box",
["rule-set", "decompile", "--output", tmpJson, tmpSrs],
{
timeout: 15000,
encoding: "utf8",
},
);
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,
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 === "POST" && req.url === "/api/route/check") {
const body = await readBody(req);
const host = String(body.host || "").trim();