diff --git a/src/server/config.js b/src/server/config.js index 3a2387c..8f1c6e2 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -14,6 +14,7 @@ export const settings = { cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db", statePath: path.join(dataDir, "state.json"), customRulesPath: path.join(dataDir, "custom-rules.json"), + customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"), subscriptionCachePath: path.join(dataDir, "subscription-cache.json"), hwidPath: path.join(dataDir, "hwid"), routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false", diff --git a/src/server/index.js b/src/server/index.js index e2440a5..d5e91ea 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -309,6 +309,9 @@ function normalizeCustomRules(input) { networks: normalizeList(rule.networks).filter((network) => ["tcp", "udp"].includes(network), ), + ruleSets: normalizeList(rule.ruleSets).filter((tag) => + /^[a-z0-9][a-z0-9-]*$/i.test(tag), + ), })); } @@ -424,6 +427,28 @@ async function handleApi(req, res) { }); } + if (req.method === "GET" && req.url === "/api/rule-sets") { + return sendJson(res, 200, { + success: true, + ruleSets: readJson(settings.customRuleSetsPath, []), + }); + } + + if (req.method === "PUT" && req.url === "/api/rule-sets") { + const body = await readBody(req); + const rawSets = Array.isArray(body.ruleSets) ? body.ruleSets : []; + const normalized = rawSets + .filter((rs) => rs && rs.tag && rs.url) + .map((rs) => ({ + tag: String(rs.tag).trim(), + url: String(rs.url).trim(), + format: rs.format === "source" ? "source" : "binary", + })) + .filter((rs) => /^[a-z0-9][a-z0-9-]*$/i.test(rs.tag)); + writeJson(settings.customRuleSetsPath, normalized); + return sendJson(res, 200, { success: true, ruleSets: normalized }); + } + if (req.method === "POST" && req.url === "/api/route/check") { const body = await readBody(req); const host = String(body.host || "").trim(); diff --git a/src/server/singbox.js b/src/server/singbox.js index 8eb816c..6adafde 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -33,25 +33,50 @@ function findOutbound(subscriptionConfig, selectedTag) { ); } -function ruleSets() { - if (!settings.routingRuDirect) return []; +function readCustomRuleSets() { + try { + if (!fs.existsSync(settings.customRuleSetsPath)) return []; + const data = JSON.parse(fs.readFileSync(settings.customRuleSetsPath, "utf8")); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} - return [ - { +function ruleSets(customRuleSets = []) { + const builtIn = settings.routingRuDirect + ? [ + { + type: "remote", + tag: "geoip-ru", + format: "binary", + url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs", + download_detour: "direct", + }, + { + type: "remote", + tag: "geosite-category-ru", + format: "binary", + url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs", + download_detour: "direct", + }, + ] + : []; + + const custom = (Array.isArray(customRuleSets) ? customRuleSets : []) + .filter((rs) => rs.tag && rs.url) + .map((rs) => ({ type: "remote", - tag: "geoip-ru", - format: "binary", - url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs", + tag: String(rs.tag).trim(), + format: rs.format || "binary", + url: String(rs.url).trim(), download_detour: "direct", - }, - { - type: "remote", - tag: "geosite-category-ru", - format: "binary", - url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs", - download_detour: "direct", - }, - ]; + })); + + // Пользовательские rule-sets не должны дублировать встроенные + const builtInTags = new Set(builtIn.map((rs) => rs.tag)); + const merged = [...builtIn, ...custom.filter((rs) => !builtInTags.has(rs.tag))]; + return merged; } function uniqueClean(values) { @@ -91,13 +116,17 @@ function toSingboxRule(customRule, vpnTag) { if (ports.length) rule.port = ports; if (networks.length) rule.network = networks; + const ruleSetsRef = uniqueClean(customRule.ruleSets); + if (ruleSetsRef.length) rule.rule_set = ruleSetsRef; + if ( !rule.domain && !rule.domain_suffix && !rule.domain_keyword && !rule.ip_cidr && !rule.port && - !rule.network + !rule.network && + !rule.rule_set ) { return null; } @@ -144,6 +173,8 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) { vpnOutbound.packet_encoding = "xudp"; } + const customRuleSets = readCustomRuleSets(); + return { log: { level: settings.logLevel, @@ -182,7 +213,7 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) { { type: "block", tag: "block" }, ], route: { - rule_set: ruleSets(), + rule_set: ruleSets(customRuleSets), rules: routeRules(subscriptionConfig.customRules, vpnOutbound.tag), final: vpnOutbound.tag, auto_detect_interface: true, diff --git a/src/web/api.js b/src/web/api.js index b5edd77..a439112 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -26,6 +26,12 @@ export const api = { conflicts: () => request("/api/rules/conflicts"), }, + ruleSets: { + get: () => request("/api/rule-sets"), + save: (ruleSets) => + request("/api/rule-sets", { method: "PUT", body: JSON.stringify({ ruleSets }) }), + }, + subscription: { fetch: (url) => request("/api/subscription/fetch", { diff --git a/src/web/components/RoutingPage.jsx b/src/web/components/RoutingPage.jsx index 80bacd9..76769a6 100644 --- a/src/web/components/RoutingPage.jsx +++ b/src/web/components/RoutingPage.jsx @@ -102,11 +102,16 @@ export function RoutingPage({ const [editingId, setEditingId] = useState(null); const [showTemplates, setShowTemplates] = useState(false); const [conflicts, setConflicts] = useState([]); + const [availableRuleSets, setAvailableRuleSets] = useState([]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); + useEffect(() => { + api.ruleSets.get().then((data) => setAvailableRuleSets(data.ruleSets || [])).catch(() => {}); + }, []); + useEffect(() => { let cancelled = false; const t = setTimeout(() => { @@ -216,6 +221,7 @@ export function RoutingPage({ onUpdate={onUpdate} onClose={() => setEditingId(null)} onRemove={onRemove} + availableRuleSets={availableRuleSets} /> setShowTemplates(false)} onAdd={onAddTemplate} /> diff --git a/src/web/components/RuleEditorDrawer.jsx b/src/web/components/RuleEditorDrawer.jsx index 0a9fec2..5ee3d97 100644 --- a/src/web/components/RuleEditorDrawer.jsx +++ b/src/web/components/RuleEditorDrawer.jsx @@ -3,9 +3,11 @@ import { ChipsInput } from './ChipsInput.jsx'; import { isValidCidr, isValidPort, ruleErrors, hasErrors } from '../utils/validation.js'; const DOMAIN = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i; +const RULE_SET_TAG = /^[a-z0-9][a-z0-9-]*$/i; const validDomain = (v) => DOMAIN.test(String(v).trim()); +const validRuleSetTag = (v) => RULE_SET_TAG.test(String(v).trim()); -export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder' }) { +export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder', availableRuleSets = [] }) { const [view, setView] = useState(mode); // builder | json const [jsonDraft, setJsonDraft] = useState(() => JSON.stringify(rule, null, 2)); const [jsonError, setJsonError] = useState(''); @@ -61,6 +63,41 @@ export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder' +
+ Rule-sets (geo-базы) + patch({ ruleSets: v })} + placeholder="geosite-runet" + validate={validRuleSetTag} + /> + {availableRuleSets.length > 0 && ( +
+ Доступны:{' '} + {availableRuleSets.map((rs) => ( + + ))} +
+ )} + {availableRuleSets.length === 0 && ( + + Настройте rule-sets в Настройках, затем вводите их теги здесь + + )} +
+
Домены (точное совпадение)
- +
diff --git a/src/web/components/SettingsPage.jsx b/src/web/components/SettingsPage.jsx index bd99edd..2e613b0 100644 --- a/src/web/components/SettingsPage.jsx +++ b/src/web/components/SettingsPage.jsx @@ -2,6 +2,24 @@ import React, { useEffect, useState } from 'react'; import { api } from '../api.js'; import { formatRelative } from '../utils/format.js'; +const SUGGESTED_RULE_SETS = [ + { + tag: 'geosite-runet', + url: 'https://github.com/runetfreedom/russia-blocked-geosite/releases/latest/download/rule-set/ru.srs', + label: 'runetfreedom / RU заблокированные домены', + }, + { + tag: 'geoip-ru-loyalsoldier', + url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/sing-box-rules@rule-set/geoip-ru.srs', + label: 'Loyalsoldier / geoip-ru', + }, + { + tag: 'geosite-category-ru-loyalsoldier', + url: 'https://cdn.jsdelivr.net/gh/Loyalsoldier/sing-box-rules@rule-set/geosite-category-ru.srs', + label: 'Loyalsoldier / geosite-category-ru', + }, +]; + function SubscriptionCard({ state, subscriptionUrl, setSubscriptionUrl, busy, onFetch, onForget, pushToast }) { const [editing, setEditing] = useState(!state?.hasSubscription); @@ -126,6 +144,143 @@ function ConfigCard({ state, busy, onShowConfig, onClearConfig, pushToast }) { ); } +function RuleSetsCard({ pushToast }) { + const [ruleSets, setRuleSets] = useState([]); + const [newTag, setNewTag] = useState(''); + const [newUrl, setNewUrl] = useState(''); + const [busy, setBusy] = useState(false); + + useEffect(() => { + api.ruleSets.get().then((d) => setRuleSets(d.ruleSets || [])).catch(() => {}); + }, []); + + async function save(next) { + setBusy(true); + try { + const data = await api.ruleSets.save(next); + setRuleSets(data.ruleSets || []); + pushToast({ kind: 'success', title: 'Rule-sets сохранены' }); + } catch (err) { + pushToast({ kind: 'danger', title: 'Ошибка', message: err.message }); + } finally { + setBusy(false); + } + } + + function addNew() { + const tag = newTag.trim(); + const url = newUrl.trim(); + if (!tag || !url) return; + if (!/^[a-z0-9][a-z0-9-]*$/i.test(tag)) { + pushToast({ kind: 'danger', title: 'Невалидный тег', message: 'Только буквы, цифры, дефис' }); + return; + } + if (ruleSets.some((rs) => rs.tag === tag)) { + pushToast({ kind: 'danger', title: 'Тег уже существует' }); + return; + } + const next = [...ruleSets, { tag, url }]; + setNewTag(''); + setNewUrl(''); + save(next); + } + + function remove(tag) { + save(ruleSets.filter((rs) => rs.tag !== tag)); + } + + function addSuggested(suggested) { + if (ruleSets.some((rs) => rs.tag === suggested.tag)) { + pushToast({ kind: 'info', title: `${suggested.tag} уже добавлен` }); + return; + } + save([...ruleSets, { tag: suggested.tag, url: suggested.url }]); + } + + return ( +
+
+

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

+
+ + Пользовательские geo-базы в формате .srs (sing-box rule-set binary). + После добавления тег можно указать в правиле маршрутизации. + + + {ruleSets.length > 0 && ( + + + + + + + + + + {ruleSets.map((rs) => ( + + + + + + ))} + +
ТегURL
{rs.tag}{rs.url} + +
+ )} + +
+ Добавить вручную +
+ setNewTag(e.target.value)} + /> + setNewUrl(e.target.value)} + /> + +
+
+ +
+ Готовые источники +
+ {SUGGESTED_RULE_SETS.map((s) => ( +
+ + {s.tag} + {s.label} + + +
+ ))} +
+ + Sing-box скачает эти файлы автоматически при первом запуске. + .dat файлы (v2ray) не поддерживаются — используйте .srs эквиваленты. + +
+
+ ); +} + function PortsCard({ state }) { return (
@@ -165,6 +320,7 @@ export function SettingsPage({ onClearConfig={onClearConfig} pushToast={pushToast} /> +
);