feat: добавлена возможность поиска и декомпиляции rule-sets
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 2s
Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 2s
Refs: None
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user