import React, { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '../api.js'; import { formatRelative } from '../utils/format.js'; 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 формат) // Источники: SagerNet (официальные, используются как встроенные), runetfreedom (RKN-реестр) const RULE_SET_CATALOG = [ { tag: 'geosite-runet', url: 'https://github.com/runetfreedom/russia-blocked-geosite/releases/latest/download/rule-set/ru.srs', source: 'runetfreedom', category: 'RU', description: 'Заблокированные в РФ домены по реестру РКН. Обновляется автоматически из официальных источников.', examples: ['rutracker.org', 'youtube.com', 'instagram.com', 'facebook.com', 'twitter.com'], use: 'vpn — маршрутизировать заблокированные домены через VPN.', builtIn: false, }, { tag: 'geoip-ru', url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs', source: 'SagerNet/sing-geoip', 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.', builtIn: true, }, { tag: 'geosite-category-ru', url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs', source: 'SagerNet/sing-geosite', category: 'RU', description: 'Домены российских сервисов: Яндекс, VK, Mail.ru, Сбербанк, банки, госуслуги. Не заблокированные, а просто российские.', examples: ['yandex.ru', 'vk.com', 'mail.ru', 'sberbank.ru', 'gosuslugi.ru', 'ozon.ru'], use: 'direct — чтобы российские сайты открывались с российским IP (нужно для оплаты и т.п.).', builtIn: true, }, { tag: 'geosite-google', url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-google.srs', source: 'SagerNet/sing-geosite', category: 'Сервисы', description: 'Все домены Google: поиск, Gmail, YouTube, Drive, Maps, Google API, reCAPTCHA и пр.', examples: ['google.com', 'googleapis.com', 'googlevideo.com', 'gstatic.com', 'ggpht.com'], use: 'vpn — если Google заблокирован или нужна стабильная работа сервисов.', builtIn: false, }, { tag: 'geosite-youtube', url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-youtube.srs', source: 'SagerNet/sing-geosite', category: 'Сервисы', description: 'Только домены YouTube и связанных CDN. Меньше чем полный Google.', examples: ['youtube.com', 'youtu.be', 'ytimg.com', 'googlevideo.com'], use: 'vpn — для разблокировки YouTube.', builtIn: false, }, { tag: 'geosite-telegram', url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-telegram.srs', source: 'SagerNet/sing-geosite', category: 'Сервисы', description: 'Домены и IP Telegram. Включает CDN, API и голосовые серверы.', examples: ['telegram.org', 't.me', 'telegra.ph', '149.154.160.0/20'], use: 'vpn — разблокировка в РФ. direct — если хочешь избежать задержек.', builtIn: false, }, { tag: 'geosite-openai', url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-openai.srs', source: 'SagerNet/sing-geosite', category: 'Сервисы', description: 'ChatGPT, OpenAI API, Dall-E и другие сервисы OpenAI.', examples: ['openai.com', 'chatgpt.com', 'oaistatic.com', 'oaiusercontent.com'], use: 'vpn — OpenAI заблокирован в РФ и ряде других стран.', builtIn: false, }, { tag: 'geosite-apple', url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-apple.srs', source: 'SagerNet/sing-geosite', 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.', builtIn: false, }, { tag: 'geosite-github', url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-github.srs', source: 'SagerNet/sing-geosite', category: 'Разработка', description: 'GitHub, GitHub Actions, GitHub Pages, raw.githubusercontent.com.', examples: ['github.com', 'githubusercontent.com', 'github.io', 'githubassets.com'], use: 'vpn — если GitHub замедлен или заблокирован.', builtIn: false, }, { tag: 'geosite-category-ads-all', url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ads-all.srs', source: 'SagerNet/sing-geosite', category: 'Блокировка', description: 'Рекламные сети, трекеры, аналитика. Тысячи доменов.', examples: ['doubleclick.net', 'googlesyndication.com', 'amazon-adsystem.com'], use: 'block — блокировка рекламы и трекеров на уровне DNS.', builtIn: false, }, ]; function SubscriptionCard({ state, subscriptionUrl, setSubscriptionUrl, busy, onFetch, onForget, pushToast }) { const [editing, setEditing] = useState(!state?.hasSubscription); useEffect(() => { if (!state?.hasSubscription) setEditing(true); }, [state?.hasSubscription]); const masked = state?.hasSubscription && !editing; return (

Подписка

{state?.hasSubscription && ( ● активна )}
{masked ? (
URL {state.subscriptionHost}
Серверов {state.servers?.length || 0}
Загружено {state.fetchedAt ? formatRelative(state.fetchedAt) : '—'}
) : (
Subscription URL
setSubscriptionUrl(e.target.value)} placeholder="https://provider.example/sub/..." />
)}
{masked ? ( <> ) : ( <> {state?.hasSubscription && ( )} )}
); } function ConfigCard({ state, busy, onShowConfig, onClearConfig, pushToast }) { const [validation, setValidation] = useState(null); const [validating, setValidating] = useState(false); async function validate() { setValidating(true); try { const data = await api.configValidate(); setValidation(data); pushToast({ kind: data.valid ? 'success' : 'danger', title: data.valid ? 'Config валиден' : 'Config невалиден', message: data.error || data.note, }); } catch (err) { pushToast({ kind: 'danger', title: 'Ошибка проверки', message: err.message }); } finally { setValidating(false); } } return (

sing-box config

{validation && ( {validation.valid ? '✓ валиден' : '✗ ошибка'} )}
Файл{state?.configExists ? 'есть' : 'нет'}
Применено{state?.appliedAt ? formatRelative(state.appliedAt) : '—'}
{validation && !validation.valid && validation.error && (
{validation.error}
)}
); } 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.builtIn && ( встроен )}
{entry.description}
{open && (
Примеры содержимого: {entry.examples.map((ex, i) => ( {ex} {i < entry.examples.length - 1 ? ' ' : ''} ))}
Рекомендуемый outbound: {entry.use}
)}
); } function SagerNetSearchCard({ ruleSets, onAdd, busy }) { const [open, setOpen] = useState(false); const [status, setStatus] = useState('idle'); // idle | loading | done | error const [catalog, setCatalog] = useState(null); // { geosite, geoip, cachedAt } const [error, setError] = useState(''); const [query, setQuery] = useState(''); const [repoFilter, setRepoFilter] = useState('all'); // all | geosite | geoip function load() { if (status !== 'idle') return; setStatus('loading'); api.ruleSets.sagernetCatalog() .then((d) => { setCatalog(d); setStatus('done'); }) .catch((err) => { setError(err.message); setStatus('error'); }); } function toggle() { if (!open && status === 'idle') load(); setOpen((o) => !o); } const results = useMemo(() => { if (!catalog) return []; const q = query.trim().toLowerCase(); const toItem = (repo) => (name) => ({ name, repo, url: `https://cdn.jsdelivr.net/gh/SagerNet/sing-${repo}@rule-set/${name}.srs` }); const gs = repoFilter !== 'geoip' ? (catalog.geosite || []).map(toItem('geosite')) : []; const gi = repoFilter !== 'geosite' ? (catalog.geoip || []).map(toItem('geoip')) : []; const all = [...gs, ...gi]; if (!q) return all; return all.filter((item) => item.name.includes(q)); }, [catalog, query, repoFilter]); const addedTags = new Set(ruleSets.map((rs) => rs.tag)); return (

Поиск в каталоге SagerNet

{status === 'done' && catalog && ( {(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} rule-sets )} {open ? '▲' : '▼'}
{open && ( <> {status === 'loading' && (
Загрузка списка из GitHub…
)} {status === 'error' && (
{error}
)} {status === 'done' && ( <> Полный список rule-sets из репозиториев SagerNet/sing-geosite и SagerNet/sing-geoip. Ищите по имени: steam, gaming, netflix, apple и т.д. Кеш обновляется раз в 24 ч. {catalog.fallback && (
!
{catalog.warning || 'Показан встроенный fallback-каталог.'}
)}
setQuery(e.target.value)} autoFocus />
{query.trim() === '' ? (
Введите запрос — покажем совпадения ({(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} доступно)
) : results.length === 0 ? (
Ничего не найдено
) : ( {results.slice(0, 100).map((item) => ( ))}
Тип Тег
{item.repo} {item.name} {addedTags.has(item.name) ? ( ✓ добавлен ) : ( )}
)} {results.length > 100 && (
Показано 100 из {results.length} — уточните запрос
)}
кеш: {catalog.cachedAt ? formatRelative(catalog.cachedAt) : '—'}
)} )}
); } 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(() => {}); }, []); 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 addFromCatalog(entry) { if (ruleSets.some((rs) => rs.tag === entry.tag)) { pushToast({ kind: 'info', title: `${entry.tag} уже добавлен` }); return; } 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). Sing-box скачает их автоматически при применении. .dat файлы (v2ray) не поддерживаются. {ruleSets.length > 0 && ( <>
Подключённые
{ruleSets.map((rs) => ( ))}
Тег URL
{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)} /> setNewUrl(e.target.value)} />
{lookup && ( setLookup(null)} /> )} ); } function PortsCard({ state }) { const isClient = state?.mode === 'client'; return (

{isClient ? 'Локальные порты' : 'Порты и маршруты'}

UI:{state?.port || 3456}
HTTP/SOCKS proxy{isClient ? '127.0.0.1' : state?.proxyBindIp || '0.0.0.0'}:{state?.proxyPort || 8080}
{!isClient &&
TProxy:{state?.tproxyPort || 7895}
}
RU direct (geoip-ru){state?.routingRuDirect ? 'включено' : 'выключено'}
); } export function SettingsPage({ state, subscriptionUrl, setSubscriptionUrl, busy, onFetchSubscription, onForgetSubscription, onShowConfig, onClearConfig, pushToast, }) { return (
); }