diff --git a/src/server/index.js b/src/server/index.js index ca04c42..5690839 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -26,6 +26,62 @@ const LOG_BUFFER_SIZE = 500; const logBuffer = []; const logSubscribers = new Set(); +const TRAFFIC_BUFFER_SIZE = 500; +const trafficBuffer = []; +const trafficSubscribers = new Set(); + +// Паттерны для парсинга трафика из логов sing-box. +// sing-box пишет строки вида: +// outbound/direct[tag]: dial tcp connection to host:port from ip:port +// [router] matched rule #0 [rule-name], outbound: vpn, domain: example.com +// [TCP] DIRECT host:port --> direct +const TRAFFIC_OUTBOUND_RE = /outbound[/\\]([a-z0-9_\-]+)|\boutbound:\s*([a-z0-9_\-]+)/i; +const TRAFFIC_DEST_RE = /(?:to|dial|connection to|DIRECT|REJECT)\s+(?:tcp\s+|udp\s+)?(?:[^\s]*\s+to\s+)?([a-zA-Z0-9._\-]+|\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})/i; +const TRAFFIC_DOMAIN_RE = /\bdomain:\s*([a-zA-Z0-9._\-]+)/i; +const TRAFFIC_RULE_RE = /matched\s+rule\s+#\d+\s*\[([^\]]+)\]|matched\s+\[([^\]]+)\]/i; + +function parseTrafficLine(line) { + const clean = line.replace(/\x1b\[\d+m/g, "").trim(); + + const obMatch = clean.match(TRAFFIC_OUTBOUND_RE); + if (!obMatch) return null; + const outboundRaw = (obMatch[1] || obMatch[2] || "").toLowerCase(); + if (!outboundRaw) return null; + + let category = "other"; + if (outboundRaw === "direct" || outboundRaw.startsWith("direct")) category = "direct"; + else if (outboundRaw === "block" || outboundRaw === "reject") category = "block"; + else if (outboundRaw !== "dns-out" && outboundRaw !== "dns") category = "vpn"; + else return null; // пропускаем DNS-аутбаунды + + const domainMatch = clean.match(TRAFFIC_DOMAIN_RE); + const destMatch = clean.match(TRAFFIC_DEST_RE); + + const host = domainMatch?.[1] || destMatch?.[1] || null; + const port = destMatch?.[2] ? parseInt(destMatch[2], 10) : null; + if (!host && !port) return null; + + const ruleMatch = clean.match(TRAFFIC_RULE_RE); + const matchedRule = ruleMatch?.[1] || ruleMatch?.[2] || null; + + return { + ts: new Date().toISOString(), + outbound: outboundRaw, + category, + host: host || "", + port, + matchedRule, + }; +} + +function pushTrafficEntry(entry) { + trafficBuffer.push(entry); + if (trafficBuffer.length > TRAFFIC_BUFFER_SIZE) trafficBuffer.shift(); + for (const sub of trafficSubscribers) { + try { sub(entry); } catch {} + } +} + function pushLog(level, line) { const entry = { ts: new Date().toISOString(), level, line }; logBuffer.push(entry); @@ -35,6 +91,11 @@ function pushLog(level, line) { subscriber(entry); } catch {} } + // Парсим трафик из info/debug строк + if (level === "info" || level === "debug") { + const traffic = parseTrafficLine(line); + if (traffic) pushTrafficEntry(traffic); + } } // Sing-box пишет все логи в stderr, поэтому парсим уровень из содержимого строки. @@ -401,6 +462,28 @@ async function handleApi(req, res) { return handleLogsStream(req, res); } + if (req.method === "GET" && req.url === "/api/traffic/stream") { + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + "x-accel-buffering": "no", + }); + for (const entry of trafficBuffer.slice(-200)) { + res.write(`data: ${JSON.stringify(entry)}\n\n`); + } + const sub = (entry) => res.write(`data: ${JSON.stringify(entry)}\n\n`); + trafficSubscribers.add(sub); + const keepalive = setInterval(() => { try { res.write(": ping\n\n"); } catch {} }, 15000); + req.on("close", () => { clearInterval(keepalive); trafficSubscribers.delete(sub); }); + return; + } + + if (req.method === "DELETE" && req.url === "/api/traffic") { + trafficBuffer.splice(0); + return sendJson(res, 200, { success: true }); + } + if (req.method === "GET" && req.url === "/api/rules") { return sendJson(res, 200, { success: true, diff --git a/src/web/components/LogsPage.jsx b/src/web/components/LogsPage.jsx index 153efe4..1112825 100644 --- a/src/web/components/LogsPage.jsx +++ b/src/web/components/LogsPage.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { formatTime } from '../utils/format.js'; const MAX_ENTRIES = 800; +const MAX_TRAFFIC = 500; const GROUP_WINDOW_MS = 30_000; function normalizeLine(line) { @@ -9,7 +10,6 @@ function normalizeLine(line) { } function groupEntries(entries) { - // Группируем повторы: одинаковая нормализованная строка + одинаковый level в окне 30 сек. const out = []; for (const e of entries) { const key = `${e.level}|${normalizeLine(e.line)}`; @@ -26,7 +26,142 @@ function groupEntries(entries) { return out; } +const CATEGORY_BADGE = { + direct: { cls: 'success', label: 'direct' }, + vpn: { cls: 'info', label: 'VPN' }, + block: { cls: 'danger', label: 'block' }, + other: { cls: '', label: 'other' }, +}; + +function TrafficTab() { + const [traffic, setTraffic] = useState([]); + const [paused, setPaused] = useState(false); + const [filter, setFilter] = useState('all'); // all | direct | vpn | block + const [search, setSearch] = useState(''); + const [autoscroll, setAutoscroll] = useState(true); + const containerRef = useRef(null); + const pausedRef = useRef(false); + + useEffect(() => { pausedRef.current = paused; }, [paused]); + + useEffect(() => { + const source = new EventSource('/api/traffic/stream'); + source.onmessage = (ev) => { + if (pausedRef.current) return; + try { + const entry = JSON.parse(ev.data); + setTraffic((prev) => { + const next = [...prev, entry]; + if (next.length > MAX_TRAFFIC) next.splice(0, next.length - MAX_TRAFFIC); + return next; + }); + } catch {} + }; + return () => source.close(); + }, []); + + const filtered = useMemo(() => { + let list = traffic; + if (filter !== 'all') list = list.filter((e) => e.category === filter); + if (search) { + const s = search.toLowerCase(); + list = list.filter((e) => + e.host?.toLowerCase().includes(s) || + String(e.port || '').includes(s) || + e.outbound?.toLowerCase().includes(s) || + e.matchedRule?.toLowerCase().includes(s), + ); + } + return list; + }, [traffic, filter, search]); + + useEffect(() => { + if (!autoscroll || !containerRef.current) return; + containerRef.current.scrollTop = containerRef.current.scrollHeight; + }, [filtered, autoscroll]); + + const counts = useMemo(() => { + const c = { direct: 0, vpn: 0, block: 0 }; + for (const e of traffic) if (e.category in c) c[e.category]++; + return c; + }, [traffic]); + + return ( +
+
+ setSearch(e.target.value)} + style={{ flex: 1, minWidth: 180 }} + /> + + + + +
+ + {traffic.length === 0 ? ( +
+ Ожидаем трафик… Убедитесь что sing-box запущен и уровень логов не выше INFO. +
+ ) : ( +
+ + + + + + + + + + + + {filtered.map((e, i) => { + const badge = CATEGORY_BADGE[e.category] || CATEGORY_BADGE.other; + return ( + + + + + + + + ); + })} + +
ВремяТуннельХост / IPПортПравило
{formatTime(e.ts)} + {badge.label} + {e.host || '—'}{e.port || '—'} + {e.matchedRule + ? {e.matchedRule} + : } +
+
+ )} +
+ ); +} + export function LogsPage() { + const [tab, setTab] = useState('traffic'); // traffic | logs const [entries, setEntries] = useState([]); const [paused, setPaused] = useState(false); const [filter, setFilter] = useState('all'); @@ -77,60 +212,69 @@ export function LogsPage() {

Логи sing-box

- {entries.length} / {MAX_ENTRIES} +
+ + +
-
- setSearch(e.target.value)} - style={{ flex: 1, minWidth: 200 }} - /> - - - - - -
+ {tab === 'traffic' && } -
- {filtered.length === 0 &&

Логов пока нет.

} - {filtered.map((entry, index) => { - const text = normalizeLine(entry.line); - if (grouped && entry.count > 1) { - return ( -
- {formatTime(entry.ts)} - - {entry.level} - - {text} - ×{entry.count} -
- ); - } - return ( -
copy(`${formatTime(entry.ts)} ${entry.level} ${text}`)} - title="Двойной клик — скопировать" - > - {formatTime(entry.ts)} - {entry.level} - {text} -
- ); - })} -
+ {tab === 'logs' && ( + <> +
+ setSearch(e.target.value)} + style={{ flex: 1, minWidth: 200 }} + /> + + + + + +
+ +
+ {filtered.length === 0 &&

Логов пока нет.

} + {filtered.map((entry, index) => { + const text = normalizeLine(entry.line); + if (grouped && entry.count > 1) { + return ( +
+ {formatTime(entry.ts)} + + {entry.level} + + {text} + ×{entry.count} +
+ ); + } + return ( +
copy(`${formatTime(entry.ts)} ${entry.level} ${text}`)} + title="Двойной клик — скопировать" + > + {formatTime(entry.ts)} + {entry.level} + {text} +
+ ); + })} +
+ + )}
); } diff --git a/src/web/components/RuleEditorDrawer.jsx b/src/web/components/RuleEditorDrawer.jsx index 5ee3d97..51b06e3 100644 --- a/src/web/components/RuleEditorDrawer.jsx +++ b/src/web/components/RuleEditorDrawer.jsx @@ -1,18 +1,201 @@ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { ChipsInput } from './ChipsInput.jsx'; import { isValidCidr, isValidPort, ruleErrors, hasErrors } from '../utils/validation.js'; +import { api } from '../api.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()); +const RS_PAGE_SIZE = 100; +const RS_TYPE_LABELS = { domain: 'домен', suffix: 'суффикс', keyword: 'ключ', cidr: 'CIDR', regex: 'regex' }; + +function RuleSetBrowseModal({ tag, url, rule, onPatch, onClose }) { + const [status, setStatus] = useState('loading'); + const [data, setData] = useState(null); + const [error, setError] = useState(''); + const [search, setSearch] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [page, setPage] = useState(0); + const inputRef = useRef(null); + + useEffect(() => { + api.ruleSets.lookup(tag, url) + .then((d) => { setData(d); setStatus('done'); }) + .catch((err) => { setError(err.message); setStatus('error'); }); + }, [tag, url]); + + useEffect(() => { + if (status === 'done') setTimeout(() => inputRef.current?.focus(), 50); + }, [status]); + + const filtered = useMemo(() => { + if (!data?.entries) return []; + const q = search.trim().toLowerCase(); + return data.entries.filter((e) => { + if (typeFilter !== 'all' && e.type !== typeFilter) return false; + if (!q) return true; + return e.value.toLowerCase().includes(q); + }); + }, [data, search, typeFilter]); + + function onSearchChange(v) { setSearch(v); setPage(0); } + function onTypeChange(v) { setTypeFilter(v); setPage(0); } + + function addEntry(entry) { + const val = entry.value; + switch (entry.type) { + case 'domain': { + const cur = new Set(rule.domains || []); + if (!cur.has(val)) onPatch({ domains: [...(rule.domains || []), val] }); + break; + } + case 'suffix': { + const cur = new Set(rule.domainSuffixes || []); + if (!cur.has(val)) onPatch({ domainSuffixes: [...(rule.domainSuffixes || []), val] }); + break; + } + case 'keyword': { + const cur = new Set(rule.domainKeywords || []); + if (!cur.has(val)) onPatch({ domainKeywords: [...(rule.domainKeywords || []), val] }); + break; + } + case 'cidr': { + const cur = new Set(rule.ipCidrs || []); + if (!cur.has(val)) onPatch({ ipCidrs: [...(rule.ipCidrs || []), val] }); + break; + } + default: break; + } + } + + const totalPages = Math.ceil(filtered.length / RS_PAGE_SIZE); + const pageItems = filtered.slice(page * RS_PAGE_SIZE, (page + 1) * RS_PAGE_SIZE); + + const addedValues = useMemo(() => new Set([ + ...(rule.domains || []), + ...(rule.domainSuffixes || []), + ...(rule.domainKeywords || []), + ...(rule.ipCidrs || []), + ]), [rule]); + + return ( +
+
e.stopPropagation()} + > +
+
+

Содержимое: {tag}

+ Кликните запись чтобы добавить в правило +
+ +
+ + {status === 'loading' && ( +
+ Скачивание и декомпиляция…
+ Может занять 10–30 секунд +
+ )} + {status === 'error' && ( +
+
{error}
+
+ )} + + {status === 'done' && data && ( + <> +
+ всего: {data.stats.total.toLocaleString()} + {data.stats.domain > 0 && доменов: {data.stats.domain.toLocaleString()}} + {data.stats.suffix > 0 && суффиксов: {data.stats.suffix.toLocaleString()}} + {data.stats.cidr > 0 && CIDR: {data.stats.cidr.toLocaleString()}} +
+
+ onSearchChange(e.target.value)} + /> + +
+
+ {filtered.length === 0 ? ( +
Ничего не найдено
+ ) : ( + <> +
+ {filtered.length.toLocaleString()} / {data.stats.total.toLocaleString()} + {totalPages > 1 && ` · стр. ${page + 1}/${totalPages}`} + — нажмите строку чтобы добавить в правило +
+ + + + + + {pageItems.map((e, i) => { + const already = addedValues.has(e.value); + return ( + !already && addEntry(e)} + title={already ? 'Уже добавлено' : `Добавить в ${e.type === 'cidr' ? 'IP/CIDR' : e.type === 'suffix' ? 'суффиксы' : e.type === 'keyword' ? 'ключевые слова' : 'домены'}`} + > + + + + + ); + })} + +
ТипЗначение
{RS_TYPE_LABELS[e.type] || e.type}{e.value}{already ? '✓' : '+'}
+ {totalPages > 1 && ( +
+ + + {page + 1} / {totalPages} + + +
+ )} + + )} +
+ + )} +
+
+ ); +} + 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(''); + const [browseTag, setBrowseTag] = useState(null); // { tag, url } | null const errors = ruleErrors(rule); + // Индекс URL по тегу из доступных rule-sets + const ruleSetUrlMap = useMemo(() => { + const map = {}; + for (const rs of availableRuleSets) map[rs.tag] = rs.url; + return map; + }, [availableRuleSets]); + function patch(p) { onUpdate(rule.id, p); } @@ -71,23 +254,51 @@ export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder' placeholder="geosite-runet" validate={validRuleSetTag} /> + {/* Кнопки просмотра содержимого для выбранных rule-sets */} + {(rule.ruleSets || []).length > 0 && ( +
+ {(rule.ruleSets || []).map((tag) => { + const url = ruleSetUrlMap[tag]; + return url ? ( + + ) : null; + })} +
+ )} {availableRuleSets.length > 0 && (
Доступны:{' '} {availableRuleSets.map((rs) => ( - + + + + ))}
)} @@ -198,6 +409,16 @@ export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder' )} + + {browseTag && ( + patch(p)} + onClose={() => setBrowseTag(null)} + /> + )} ); }