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' && (
+
+ )}
+
+ {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)}
+ />
+ onTypeChange(e.target.value)}>
+ Все типы
+ {Object.entries(TYPE_LABELS).map(([k, v]) => (
+ {v}
+ ))}
+
+
+
+ {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 && (
+
+ setPage(0)}>«
+ setPage((p) => p - 1)}>‹
+ {page + 1} / {totalPages}
+ = totalPages - 1} onClick={() => setPage((p) => p + 1)}>›
+ = totalPages - 1} onClick={() => setPage(totalPages - 1)}>»
+
+ )}
+ >
+ )}
+
+ >
+ )}
+
+
+ );
+}
+
+// Каталог готовых 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}
+
+
+ setOpen((o) => !o)}
+ title="Примеры и подсказка"
+ >
+ {open ? '▲' : '▼'}
+
+ onLookup(entry)}
+ title="Просмотреть содержимое и искать внутри"
+ >
+ 🔍
+
+ onAdd(entry)}
+ >
+ {added ? '✓ добавлен' : '+ Добавить'}
+
+
+
+ {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 && (
-
-
-
- Тег
- URL
-
-
-
-
- {ruleSets.map((rs) => (
-
- {rs.tag}
- {rs.url}
-
- remove(rs.tag)}>×
-
+ <>
+ Подключённые
+
+
+
+ Тег
+ URL
+
- ))}
-
-
+
+
+ {ruleSets.map((rs) => (
+
+ {rs.tag}
+ {rs.url}
+
+ setLookup(rs)} title="Просмотреть содержимое">🔍
+ remove(rs.tag)}>×
+
+
+ ))}
+
+
+ >
)}
-
-
Добавить вручную
+
Каталог
+
+ setSearch(e.target.value)}
+ />
+ setCategory(e.target.value)}>
+ {CATALOG_CATEGORIES.map((c) => {c} )}
+
+
+
+ {filtered.length === 0 && (
+
Ничего не найдено
+ )}
+ {filtered.map((entry) => (
+
rs.tag === entry.tag)}
+ busy={busy}
+ onAdd={addFromCatalog}
+ onLookup={(e) => setLookup(e)}
+ />
+ ))}
+
+
-
-
-
Готовые источники
-
- {SUGGESTED_RULE_SETS.map((s) => (
-
-
- {s.tag}
- {s.label}
-
- rs.tag === s.tag)}
- onClick={() => addSuggested(s)}
- >
- {ruleSets.some((rs) => rs.tag === s.tag) ? '✓ добавлен' : '+ Добавить'}
-
-
- ))}
-
-
- Sing-box скачает эти файлы автоматически при первом запуске.
- .dat файлы (v2ray) не поддерживаются — используйте .srs эквиваленты.
-
-
+ {lookup && (
+ setLookup(null)}
+ />
+ )}
);
}