From 7d1f5f89edcb6e72638284e0ba3035b8ea9158e1 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Fri, 8 May 2026 20:15:33 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B8=20=D0=B4=D0=B5=D0=BA=D0=BE=D0=BC=D0=BF=D0=B8=D0=BB?= =?UTF-8?q?=D1=8F=D1=86=D0=B8=D0=B8=20rule-sets=20Refs:=20None?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/index.js | 123 ++++++++ src/web/api.js | 5 + src/web/components/SettingsPage.jsx | 453 ++++++++++++++++++++++++---- 3 files changed, 519 insertions(+), 62 deletions(-) diff --git a/src/server/index.js b/src/server/index.js index d5e91ea..d50279a 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -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(); diff --git a/src/web/api.js b/src/web/api.js index a22de24..24f5c74 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -33,6 +33,11 @@ export const api = { method: "PUT", body: JSON.stringify({ ruleSets }), }), + lookup: (tag, url) => + request("/api/rule-sets/lookup", { + method: "POST", + body: JSON.stringify({ tag, url }), + }), }, subscription: { diff --git a/src/web/components/SettingsPage.jsx b/src/web/components/SettingsPage.jsx index 2e613b0..563bee2 100644 --- a/src/web/components/SettingsPage.jsx +++ b/src/web/components/SettingsPage.jsx @@ -1,22 +1,242 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '../api.js'; import { formatRelative } from '../utils/format.js'; -const SUGGESTED_RULE_SETS = [ +const TYPE_LABELS = { domain: 'домен', suffix: 'суффикс', keyword: 'ключевое слово', cidr: 'CIDR', regex: 'regex' }; +const PAGE_SIZE = 100; + +function RuleSetLookupModal({ tag, url, onClose }) { + const [state, setState] = useState('idle'); // idle | loading | done | error + const [error, setError] = useState(''); + const [data, setData] = useState(null); // { entries, stats, cachedAt } + const [search, setSearch] = useState(''); + const [filterType, setFilterType] = useState('all'); + const [page, setPage] = useState(0); + const inputRef = useRef(null); + + useEffect(() => { + setState('loading'); + api.ruleSets.lookup(tag, url) + .then((res) => { setData(res); setState('done'); }) + .catch((err) => { setError(err.message); setState('error'); }); + }, [tag, url]); + + useEffect(() => { + if (state === 'done') setTimeout(() => inputRef.current?.focus(), 50); + }, [state]); + + const filtered = useMemo(() => { + if (!data?.entries) return []; + const q = search.trim().toLowerCase(); + return data.entries.filter((e) => { + if (filterType !== 'all' && e.type !== filterType) return false; + if (!q) return true; + return e.value.toLowerCase().includes(q); + }); + }, [data, search, filterType]); + + const totalPages = Math.ceil(filtered.length / PAGE_SIZE); + const pageItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); + + function onSearchChange(v) { setSearch(v); setPage(0); } + function onTypeChange(v) { setFilterType(v); setPage(0); } + + return ( +
+
e.stopPropagation()}> +
+
+

Содержимое: {tag}

+ {url} +
+ +
+ + {state === 'loading' && ( +
+ Скачивание и декомпиляция…
+ Может занять 10–30 секунд +
+ )} + + {state === 'error' && ( +
+
{error}
+
+ )} + + {state === 'done' && data && ( + <> +
+
+ всего: {data.stats.total.toLocaleString()} + {data.stats.domain > 0 && доменов: {data.stats.domain.toLocaleString()}} + {data.stats.suffix > 0 && суффиксов: {data.stats.suffix.toLocaleString()}} + {data.stats.keyword > 0 && ключ. слов: {data.stats.keyword.toLocaleString()}} + {data.stats.cidr > 0 && CIDR: {data.stats.cidr.toLocaleString()}} + {data.stats.regex > 0 && regex: {data.stats.regex.toLocaleString()}} + + кеш: {formatRelative(data.cachedAt)} + +
+
+
+ onSearchChange(e.target.value)} + /> + +
+
+ {filtered.length === 0 ? ( +
Ничего не найдено
+ ) : ( + <> +
+ Найдено: {filtered.length.toLocaleString()} / {data.stats.total.toLocaleString()} + {totalPages > 1 && ` · страница ${page + 1} из ${totalPages}`} +
+ + + + + + {pageItems.map((e, i) => ( + + + + + ))} + +
ТипЗначение
{TYPE_LABELS[e.type] || e.type}{e.value}
+ {totalPages > 1 && ( +
+ + + {page + 1} / {totalPages} + + +
+ )} + + )} +
+ + )} +
+
+ ); +} + +// Каталог готовых rule-set источников для sing-box (.srs формат) +const RULE_SET_CATALOG = [ { tag: 'geosite-runet', url: 'https://github.com/runetfreedom/russia-blocked-geosite/releases/latest/download/rule-set/ru.srs', - label: 'runetfreedom / RU заблокированные домены', + source: 'runetfreedom', + category: 'RU', + description: 'Заблокированные в РФ домены по реестру РКН. Обновляется автоматически из официальных источников.', + examples: ['rutracker.org', 'youtube.com', 'instagram.com', 'facebook.com', 'twitter.com'], + use: 'direct или vpn — зависит от цели. direct — обход блокировок с российского IP.', }, { - tag: 'geoip-ru-loyalsoldier', + tag: 'geoip-ru', url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/sing-box-rules@rule-set/geoip-ru.srs', - label: 'Loyalsoldier / geoip-ru', + source: 'Loyalsoldier', + category: 'RU', + description: 'IP-диапазоны, зарегистрированные в России (RIPE NCC). Покрывает российские хостинги, банки, госсайты.', + examples: ['77.88.0.0/18 (Яндекс)', '95.173.128.0/19 (МТС)', '213.180.192.0/19 (Яндекс)'], + use: 'direct — российские сервисы без VPN. vpn — если сервер в РФ.', }, { - tag: 'geosite-category-ru-loyalsoldier', + tag: 'geosite-category-ru', url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/sing-box-rules@rule-set/geosite-category-ru.srs', - label: 'Loyalsoldier / geosite-category-ru', + source: 'Loyalsoldier', + category: 'RU', + description: 'Домены российских сервисов: Яндекс, VK, Mail.ru, Сбербанк, банки, госуслуги. Не заблокированные, а просто российские.', + examples: ['yandex.ru', 'vk.com', 'mail.ru', 'sberbank.ru', 'gosuslugi.ru', 'ozon.ru'], + use: 'direct — чтобы российские сайты открывались с российским IP (нужно для оплаты и т.п.).', + }, + { + tag: 'geosite-google', + url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/sing-box-rules@rule-set/geosite-google.srs', + source: 'Loyalsoldier', + category: 'Сервисы', + description: 'Все домены Google: поиск, Gmail, YouTube, Drive, Maps, Google API, reCAPTCHA и пр.', + examples: ['google.com', 'googleapis.com', 'googlevideo.com', 'gstatic.com', 'ggpht.com'], + use: 'vpn — если Google заблокирован или нужна стабильная работа сервисов.', + }, + { + tag: 'geosite-youtube', + url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/sing-box-rules@rule-set/geosite-youtube.srs', + source: 'Loyalsoldier', + category: 'Сервисы', + description: 'Только домены YouTube и связанных CDN. Меньше чем полный Google.', + examples: ['youtube.com', 'youtu.be', 'ytimg.com', 'googlevideo.com'], + use: 'vpn — для разблокировки YouTube.', + }, + { + tag: 'geosite-telegram', + url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/sing-box-rules@rule-set/geosite-telegram.srs', + source: 'Loyalsoldier', + category: 'Сервисы', + description: 'Домены и IP Telegram. Включает CDN, API и голосовые серверы.', + examples: ['telegram.org', 't.me', 'telegra.ph', '149.154.160.0/20'], + use: 'vpn — разблокировка в РФ. direct — если хочешь избежать задержек.', + }, + { + tag: 'geosite-openai', + url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/sing-box-rules@rule-set/geosite-openai.srs', + source: 'Loyalsoldier', + category: 'Сервисы', + description: 'ChatGPT, OpenAI API, Dall-E и другие сервисы OpenAI.', + examples: ['openai.com', 'chatgpt.com', 'oaistatic.com', 'oaiusercontent.com'], + use: 'vpn — OpenAI заблокирован в РФ и ряде других стран.', + }, + { + tag: 'geosite-apple', + url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/sing-box-rules@rule-set/geosite-apple.srs', + source: 'Loyalsoldier', + category: 'Сервисы', + description: 'App Store, iCloud, Apple CDN, push-уведомления (APNs), iMessage.', + examples: ['apple.com', 'icloud.com', 'mzstatic.com', 'apple-cloudkit.com'], + use: 'direct — Apple обычно работает без VPN. vpn — если нужен другой регион App Store.', + }, + { + tag: 'geosite-github', + url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/sing-box-rules@rule-set/geosite-github.srs', + source: 'Loyalsoldier', + category: 'Разработка', + description: 'GitHub, GitHub Actions, GitHub Pages, raw.githubusercontent.com.', + examples: ['github.com', 'githubusercontent.com', 'github.io', 'githubassets.com'], + use: 'vpn — если GitHub замедлен или заблокирован.', + }, + { + tag: 'geosite-category-ads-all', + url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/sing-box-rules@rule-set/geosite-category-ads-all.srs', + source: 'Loyalsoldier', + category: 'Блокировка', + description: 'Рекламные сети, трекеры, аналитика. Тысячи доменов.', + examples: ['doubleclick.net', 'googlesyndication.com', 'amazon-adsystem.com', 'yandex-team.ru/ads'], + use: 'block — блокировка рекламы и трекеров на уровне DNS.', + }, + { + tag: 'geoip-private', + url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/sing-box-rules@rule-set/geoip-private.srs', + source: 'Loyalsoldier', + category: 'Сеть', + description: 'Приватные IP-диапазоны: локальная сеть, loopback, link-local. Обычно не нужен — sing-box имеет встроенный ip_is_private.', + examples: ['10.0.0.0/8', '192.168.0.0/16', '172.16.0.0/12', '127.0.0.0/8'], + use: 'direct — локальный трафик всегда напрямую.', }, ]; @@ -144,11 +364,94 @@ function ConfigCard({ state, busy, onShowConfig, onClearConfig, pushToast }) { ); } +const CATALOG_CATEGORIES = ['Все', ...Array.from(new Set(RULE_SET_CATALOG.map((r) => r.category)))]; + +function CatalogEntry({ entry, added, busy, onAdd, onLookup }) { + const [open, setOpen] = useState(false); + return ( +
+
+
+
+ {entry.tag} + {entry.category} + {entry.source} +
+
{entry.description}
+
+
+ + + +
+
+ {open && ( +
+
+ Примеры содержимого: + {entry.examples.map((ex, i) => ( + + {ex} + {i < entry.examples.length - 1 ? ' ' : ''} + + ))} +
+
+ Рекомендуемый outbound: + {entry.use} +
+
+ )} +
+ ); +} + {open && ( +
+
+ Примеры содержимого: + {entry.examples.map((ex, i) => ( + + {ex} + {i < entry.examples.length - 1 ? ' ' : ''} + + ))} +
+
+ Рекомендуемый outbound: + {entry.use} +
+
+ )} + + ); +} + function RuleSetsCard({ pushToast }) { const [ruleSets, setRuleSets] = useState([]); const [newTag, setNewTag] = useState(''); const [newUrl, setNewUrl] = useState(''); const [busy, setBusy] = useState(false); + const [search, setSearch] = useState(''); + const [category, setCategory] = useState('Все'); + const [lookup, setLookup] = useState(null); // { tag, url } useEffect(() => { api.ruleSets.get().then((d) => setRuleSets(d.ruleSets || [])).catch(() => {}); @@ -189,54 +492,98 @@ function RuleSetsCard({ pushToast }) { save(ruleSets.filter((rs) => rs.tag !== tag)); } - function addSuggested(suggested) { - if (ruleSets.some((rs) => rs.tag === suggested.tag)) { - pushToast({ kind: 'info', title: `${suggested.tag} уже добавлен` }); + function addFromCatalog(entry) { + if (ruleSets.some((rs) => rs.tag === entry.tag)) { + pushToast({ kind: 'info', title: `${entry.tag} уже добавлен` }); return; } - save([...ruleSets, { tag: suggested.tag, url: suggested.url }]); + save([...ruleSets, { tag: entry.tag, url: entry.url }]); } + const q = search.trim().toLowerCase(); + const filtered = RULE_SET_CATALOG.filter((entry) => { + if (category !== 'Все' && entry.category !== category) return false; + if (!q) return true; + return ( + entry.tag.includes(q) || + entry.description.toLowerCase().includes(q) || + entry.source.toLowerCase().includes(q) || + entry.examples.some((ex) => ex.toLowerCase().includes(q)) + ); + }); + return (

Источники (rule-sets)

- - Пользовательские geo-базы в формате .srs (sing-box rule-set binary). - После добавления тег можно указать в правиле маршрутизации. + + Geo-базы в формате .srs (sing-box). Sing-box скачает их автоматически при применении. + .dat файлы (v2ray) не поддерживаются. {ruleSets.length > 0 && ( - - - - - - - - - - {ruleSets.map((rs) => ( - - - - + <> +
Подключённые
+
ТегURL
{rs.tag}{rs.url} - -
+ + + + + - ))} - -
ТегURL
+ + + {ruleSets.map((rs) => ( + + {rs.tag} + {rs.url} + + + + + + ))} + + + )} -
- Добавить вручную +
Каталог
+
+ setSearch(e.target.value)} + /> + +
+ + {filtered.length === 0 && ( +
Ничего не найдено
+ )} + {filtered.map((entry) => ( + rs.tag === entry.tag)} + busy={busy} + onAdd={addFromCatalog} + onLookup={(e) => setLookup(e)} + /> + ))} + +
+ Добавить свой .srs
setNewTag(e.target.value)} /> @@ -252,32 +599,14 @@ function RuleSetsCard({ pushToast }) {
- -
- Готовые источники -
- {SUGGESTED_RULE_SETS.map((s) => ( -
- - {s.tag} - {s.label} - - -
- ))} -
- - Sing-box скачает эти файлы автоматически при первом запуске. - .dat файлы (v2ray) не поддерживаются — используйте .srs эквиваленты. - -
+ {lookup && ( + setLookup(null)} + /> + )} ); }