feat: добавлены функции для работы с пользовательскими rule-sets

Добавлены новые API-методы для получения и сохранения пользовательских rule-sets. Обновлены компоненты для работы с этими данными, включая интерфейс для добавления и удаления rule-sets.

Refs: None
This commit is contained in:
2026-05-08 19:49:44 +03:00
parent 3e18b833c6
commit 27b71077b1
7 changed files with 283 additions and 21 deletions

View File

@@ -14,6 +14,7 @@ export const settings = {
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db", cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
statePath: path.join(dataDir, "state.json"), statePath: path.join(dataDir, "state.json"),
customRulesPath: path.join(dataDir, "custom-rules.json"), customRulesPath: path.join(dataDir, "custom-rules.json"),
customRuleSetsPath: path.join(dataDir, "custom-rule-sets.json"),
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"), subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
hwidPath: path.join(dataDir, "hwid"), hwidPath: path.join(dataDir, "hwid"),
routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false", routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false",

View File

@@ -309,6 +309,9 @@ function normalizeCustomRules(input) {
networks: normalizeList(rule.networks).filter((network) => networks: normalizeList(rule.networks).filter((network) =>
["tcp", "udp"].includes(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") { if (req.method === "POST" && req.url === "/api/route/check") {
const body = await readBody(req); const body = await readBody(req);
const host = String(body.host || "").trim(); const host = String(body.host || "").trim();

View File

@@ -33,25 +33,50 @@ function findOutbound(subscriptionConfig, selectedTag) {
); );
} }
function ruleSets() { function readCustomRuleSets() {
if (!settings.routingRuDirect) return []; 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", type: "remote",
tag: "geoip-ru", tag: String(rs.tag).trim(),
format: "binary", format: rs.format || "binary",
url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs", url: String(rs.url).trim(),
download_detour: "direct", download_detour: "direct",
}, }));
{
type: "remote", // Пользовательские rule-sets не должны дублировать встроенные
tag: "geosite-category-ru", const builtInTags = new Set(builtIn.map((rs) => rs.tag));
format: "binary", const merged = [...builtIn, ...custom.filter((rs) => !builtInTags.has(rs.tag))];
url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs", return merged;
download_detour: "direct",
},
];
} }
function uniqueClean(values) { function uniqueClean(values) {
@@ -91,13 +116,17 @@ function toSingboxRule(customRule, vpnTag) {
if (ports.length) rule.port = ports; if (ports.length) rule.port = ports;
if (networks.length) rule.network = networks; if (networks.length) rule.network = networks;
const ruleSetsRef = uniqueClean(customRule.ruleSets);
if (ruleSetsRef.length) rule.rule_set = ruleSetsRef;
if ( if (
!rule.domain && !rule.domain &&
!rule.domain_suffix && !rule.domain_suffix &&
!rule.domain_keyword && !rule.domain_keyword &&
!rule.ip_cidr && !rule.ip_cidr &&
!rule.port && !rule.port &&
!rule.network !rule.network &&
!rule.rule_set
) { ) {
return null; return null;
} }
@@ -144,6 +173,8 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) {
vpnOutbound.packet_encoding = "xudp"; vpnOutbound.packet_encoding = "xudp";
} }
const customRuleSets = readCustomRuleSets();
return { return {
log: { log: {
level: settings.logLevel, level: settings.logLevel,
@@ -182,7 +213,7 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) {
{ type: "block", tag: "block" }, { type: "block", tag: "block" },
], ],
route: { route: {
rule_set: ruleSets(), rule_set: ruleSets(customRuleSets),
rules: routeRules(subscriptionConfig.customRules, vpnOutbound.tag), rules: routeRules(subscriptionConfig.customRules, vpnOutbound.tag),
final: vpnOutbound.tag, final: vpnOutbound.tag,
auto_detect_interface: true, auto_detect_interface: true,

View File

@@ -26,6 +26,12 @@ export const api = {
conflicts: () => request("/api/rules/conflicts"), conflicts: () => request("/api/rules/conflicts"),
}, },
ruleSets: {
get: () => request("/api/rule-sets"),
save: (ruleSets) =>
request("/api/rule-sets", { method: "PUT", body: JSON.stringify({ ruleSets }) }),
},
subscription: { subscription: {
fetch: (url) => fetch: (url) =>
request("/api/subscription/fetch", { request("/api/subscription/fetch", {

View File

@@ -102,11 +102,16 @@ export function RoutingPage({
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
const [showTemplates, setShowTemplates] = useState(false); const [showTemplates, setShowTemplates] = useState(false);
const [conflicts, setConflicts] = useState([]); const [conflicts, setConflicts] = useState([]);
const [availableRuleSets, setAvailableRuleSets] = useState([]);
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
); );
useEffect(() => {
api.ruleSets.get().then((data) => setAvailableRuleSets(data.ruleSets || [])).catch(() => {});
}, []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const t = setTimeout(() => { const t = setTimeout(() => {
@@ -216,6 +221,7 @@ export function RoutingPage({
onUpdate={onUpdate} onUpdate={onUpdate}
onClose={() => setEditingId(null)} onClose={() => setEditingId(null)}
onRemove={onRemove} onRemove={onRemove}
availableRuleSets={availableRuleSets}
/> />
<TemplatesModal open={showTemplates} onClose={() => setShowTemplates(false)} onAdd={onAddTemplate} /> <TemplatesModal open={showTemplates} onClose={() => setShowTemplates(false)} onAdd={onAddTemplate} />
</div> </div>

View File

@@ -3,9 +3,11 @@ import { ChipsInput } from './ChipsInput.jsx';
import { isValidCidr, isValidPort, ruleErrors, hasErrors } from '../utils/validation.js'; 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 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 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 [view, setView] = useState(mode); // builder | json
const [jsonDraft, setJsonDraft] = useState(() => JSON.stringify(rule, null, 2)); const [jsonDraft, setJsonDraft] = useState(() => JSON.stringify(rule, null, 2));
const [jsonError, setJsonError] = useState(''); const [jsonError, setJsonError] = useState('');
@@ -61,6 +63,41 @@ export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder'
</div> </div>
</div> </div>
<div className="field">
<span className="field-label">Rule-sets (geo-базы)</span>
<ChipsInput
value={rule.ruleSets || []}
onChange={(v) => patch({ ruleSets: v })}
placeholder="geosite-runet"
validate={validRuleSetTag}
/>
{availableRuleSets.length > 0 && (
<div className="field-hint">
Доступны:{' '}
{availableRuleSets.map((rs) => (
<button
key={rs.tag}
className="btn btn-ghost sm"
style={{ padding: '0 6px', marginRight: 4 }}
onClick={() => {
const current = new Set(rule.ruleSets || []);
if (!current.has(rs.tag)) {
patch({ ruleSets: [...(rule.ruleSets || []), rs.tag] });
}
}}
>
+ {rs.tag}
</button>
))}
</div>
)}
{availableRuleSets.length === 0 && (
<span className="field-hint">
Настройте rule-sets в Настройках, затем вводите их теги здесь
</span>
)}
</div>
<div className="field"> <div className="field">
<span className="field-label">Домены (точное совпадение)</span> <span className="field-label">Домены (точное совпадение)</span>
<ChipsInput <ChipsInput
@@ -165,7 +202,7 @@ export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder'
); );
} }
export function RuleEditorDrawer({ rule, onUpdate, onClose, onRemove }) { export function RuleEditorDrawer({ rule, onUpdate, onClose, onRemove, availableRuleSets = [] }) {
if (!rule) return null; if (!rule) return null;
const errors = ruleErrors(rule); const errors = ruleErrors(rule);
const invalid = hasErrors(errors); const invalid = hasErrors(errors);
@@ -181,7 +218,7 @@ export function RuleEditorDrawer({ rule, onUpdate, onClose, onRemove }) {
</div> </div>
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button> <button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
</div> </div>
<RuleEditor rule={rule} onUpdate={onUpdate} onClose={onClose} onRemove={onRemove} /> <RuleEditor rule={rule} onUpdate={onUpdate} onClose={onClose} onRemove={onRemove} availableRuleSets={availableRuleSets} />
<div className="drawer-foot"> <div className="drawer-foot">
<button className="btn btn-danger" onClick={() => { if (confirm('Удалить правило?')) { onRemove(rule.id); onClose(); } }}>Удалить</button> <button className="btn btn-danger" onClick={() => { if (confirm('Удалить правило?')) { onRemove(rule.id); onClose(); } }}>Удалить</button>
<div className="btn-group"> <div className="btn-group">

View File

@@ -2,6 +2,24 @@ import React, { useEffect, useState } from 'react';
import { api } from '../api.js'; import { api } from '../api.js';
import { formatRelative } from '../utils/format.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 }) { function SubscriptionCard({ state, subscriptionUrl, setSubscriptionUrl, busy, onFetch, onForget, pushToast }) {
const [editing, setEditing] = useState(!state?.hasSubscription); 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 (
<div className="card">
<div className="card-header">
<h2>Источники (rule-sets)</h2>
</div>
<small className="muted" style={{ display: 'block', marginBottom: 12 }}>
Пользовательские geo-базы в формате <strong>.srs</strong> (sing-box rule-set binary).
После добавления тег можно указать в правиле маршрутизации.
</small>
{ruleSets.length > 0 && (
<table className="table" style={{ marginBottom: 16 }}>
<thead>
<tr>
<th>Тег</th>
<th>URL</th>
<th></th>
</tr>
</thead>
<tbody>
{ruleSets.map((rs) => (
<tr key={rs.tag}>
<td className="text-mono">{rs.tag}</td>
<td className="muted" style={{ fontSize: 12, wordBreak: 'break-all' }}>{rs.url}</td>
<td style={{ textAlign: 'right' }}>
<button className="btn btn-ghost sm" disabled={busy} onClick={() => remove(rs.tag)}>×</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<div className="field">
<span className="field-label">Добавить вручную</span>
<div className="flex" style={{ gap: 8, flexWrap: 'wrap' }}>
<input
className="input"
style={{ width: 180 }}
placeholder="тег (напр. geosite-runet)"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
/>
<input
className="input"
style={{ flex: 1, minWidth: 200 }}
placeholder="https://…/rule-set.srs"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
/>
<button className="btn btn-primary" disabled={busy || !newTag || !newUrl} onClick={addNew}>
Добавить
</button>
</div>
</div>
<div className="field">
<span className="field-label">Готовые источники</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{SUGGESTED_RULE_SETS.map((s) => (
<div key={s.tag} className="flex" style={{ alignItems: 'center', gap: 8 }}>
<span style={{ flex: 1, fontSize: 13 }}>
<strong className="text-mono">{s.tag}</strong>
<span className="muted" style={{ marginLeft: 8 }}>{s.label}</span>
</span>
<button
className="btn btn-secondary sm"
disabled={busy || ruleSets.some((rs) => rs.tag === s.tag)}
onClick={() => addSuggested(s)}
>
{ruleSets.some((rs) => rs.tag === s.tag) ? '✓ добавлен' : '+ Добавить'}
</button>
</div>
))}
</div>
<small className="muted" style={{ marginTop: 8, display: 'block' }}>
Sing-box скачает эти файлы автоматически при первом запуске.
<strong>.dat файлы (v2ray) не поддерживаются</strong> используйте .srs эквиваленты.
</small>
</div>
</div>
);
}
function PortsCard({ state }) { function PortsCard({ state }) {
return ( return (
<div className="card"> <div className="card">
@@ -165,6 +320,7 @@ export function SettingsPage({
onClearConfig={onClearConfig} onClearConfig={onClearConfig}
pushToast={pushToast} pushToast={pushToast}
/> />
<RuleSetsCard pushToast={pushToast} />
<PortsCard state={state} /> <PortsCard state={state} />
</div> </div>
); );