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' && (
)}
{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 формат)
// Источники: 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) : '—'}
) : (
)}
{masked ? (
<>
setEditing(true)} disabled={busy}>Изменить URL
↻ Обновить серверы
Удалить подписку
>
) : (
<>
{ await onFetch(); setEditing(false); }}
disabled={busy || !subscriptionUrl}
>
{busy ? 'Загрузка…' : 'Загрузить серверы'}
{state?.hasSubscription && (
setEditing(false)}>Отмена
)}
>
)}
);
}
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) : '—'}
Показать config
{validating ? 'Проверяем…' : '✓ Валидировать'}
Сбросить config
{validation && !validation.valid && 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}
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}
)}
);
}
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' && (
)}
{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
/>
setRepoFilter(e.target.value)}>
geosite + geoip
только geosite
только geoip
{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) ? (
✓ добавлен
) : (
onAdd({ tag: item.name, url: item.url })}
>
+ Добавить
)}
))}
)}
{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 && (
<>
Подключённые
Тег
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)}
/>
))}
{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 (
);
}