feat: добавлена поддержка отображения устройства в журнале трафика
Refs: None
This commit is contained in:
@@ -100,9 +100,9 @@ const trafficSubscribers = new Set();
|
|||||||
// [router] match[N][rule-name] => outbound/direct[tag]
|
// [router] match[N][rule-name] => outbound/direct[tag]
|
||||||
// outbound/direct[tag]: dial tcp connection to host:port
|
// outbound/direct[tag]: dial tcp connection to host:port
|
||||||
|
|
||||||
// Назначение после --> (основной формат sing-box)
|
// Назначение после --> (старый формат sing-box)
|
||||||
const DEST_ARROW_RE = /-->\s*([\w.\-]+):(\d{1,5})/;
|
const DEST_ARROW_RE = /-->\s*([\w.\-]+):(\d{1,5})/;
|
||||||
// Назначение в старом словесном стиле
|
// Назначение в словесном стиле
|
||||||
const DEST_WORD_RE =
|
const DEST_WORD_RE =
|
||||||
/(?:connection\s+to|dial(?:ing)?|connect(?:ing)?\s+to)\s+([\w.\-]+):(\d{1,5})/i;
|
/(?:connection\s+to|dial(?:ing)?|connect(?:ing)?\s+to)\s+([\w.\-]+):(\d{1,5})/i;
|
||||||
// Тип аутбаунда: outbound/TYPE[tag] или outbound/TYPE
|
// Тип аутбаунда: outbound/TYPE[tag] или outbound/TYPE
|
||||||
@@ -110,6 +110,21 @@ const OUTBOUND_RE = /outbound\/([a-z0-9_\-]+)/i;
|
|||||||
// Строка роутера: [router] match[N][rule-name] => outbound/TYPE[tag]
|
// Строка роутера: [router] match[N][rule-name] => outbound/TYPE[tag]
|
||||||
const ROUTER_MATCH_LINE_RE =
|
const ROUTER_MATCH_LINE_RE =
|
||||||
/\[router\].*\bmatch\[\d+\]\[([^\]]+)\].*outbound\/([a-z0-9_\-]+)/i;
|
/\[router\].*\bmatch\[\d+\]\[([^\]]+)\].*outbound\/([a-z0-9_\-]+)/i;
|
||||||
|
// ID соединения: [CONN_ID Nms]
|
||||||
|
const CONN_ID_RE = /\[(\d{5,12})\s+\d+ms\]/;
|
||||||
|
// Входящее соединение от устройства: inbound [packet] connection from IP:PORT
|
||||||
|
const INBOUND_FROM_RE = /inbound(?:\s+packet)?\s+connection\s+from\s+([\d.]+):\d+/i;
|
||||||
|
// Source IP из --> формата: IP:PORT -->
|
||||||
|
const SOURCE_ARROW_RE = /\b([\d.]+):\d+\s+-->/;
|
||||||
|
// Карта source IP по ID соединения
|
||||||
|
const CONN_TTL_MS = 10_000;
|
||||||
|
const connSourceMap = new Map();
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [id, v] of connSourceMap) {
|
||||||
|
if (now - v.at > CONN_TTL_MS) connSourceMap.delete(id);
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
// Хранит имя последнего правила из [router] строки (для следующей строки с dest)
|
// Хранит имя последнего правила из [router] строки (для следующей строки с dest)
|
||||||
let _pendingRuleName = null;
|
let _pendingRuleName = null;
|
||||||
@@ -117,7 +132,8 @@ let _pendingRuleAt = 0;
|
|||||||
const RULE_CONTEXT_TTL_MS = 500;
|
const RULE_CONTEXT_TTL_MS = 500;
|
||||||
|
|
||||||
function parseTrafficLine(line) {
|
function parseTrafficLine(line) {
|
||||||
const clean = line.replace(/\x1b\[\d+m/g, "").trim();
|
// Расширенная очистка ANSI (включая многопараметрические: \x1b[38;5;Nm)
|
||||||
|
const clean = line.replace(/\x1b\[[0-9;]*m/g, "").trim();
|
||||||
|
|
||||||
// Детектируем строку роутера — содержит правило, но не dest
|
// Детектируем строку роутера — содержит правило, но не dest
|
||||||
const routerM = clean.match(ROUTER_MATCH_LINE_RE);
|
const routerM = clean.match(ROUTER_MATCH_LINE_RE);
|
||||||
@@ -127,6 +143,17 @@ function parseTrafficLine(line) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Извлекаем ID соединения для корреляции
|
||||||
|
const connM = clean.match(CONN_ID_RE);
|
||||||
|
const connId = connM ? connM[1] : null;
|
||||||
|
|
||||||
|
// Строка "inbound connection from IP:PORT" — сохраняем source IP и выходим
|
||||||
|
const inboundFromM = clean.match(INBOUND_FROM_RE);
|
||||||
|
if (inboundFromM) {
|
||||||
|
if (connId) connSourceMap.set(connId, { sourceIp: inboundFromM[1], at: Date.now() });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Берём накопленное имя правила, если свежее
|
// Берём накопленное имя правила, если свежее
|
||||||
let inheritedRule = null;
|
let inheritedRule = null;
|
||||||
if (_pendingRuleName && Date.now() - _pendingRuleAt < RULE_CONTEXT_TTL_MS) {
|
if (_pendingRuleName && Date.now() - _pendingRuleAt < RULE_CONTEXT_TTL_MS) {
|
||||||
@@ -150,19 +177,31 @@ function parseTrafficLine(line) {
|
|||||||
category = "block";
|
category = "block";
|
||||||
else category = "vpn";
|
else category = "vpn";
|
||||||
|
|
||||||
// Ищем назначение: --> (основной формат), потом словесный
|
// Ищем назначение: --> (старый формат), потом словесный (inbound/outbound connection to)
|
||||||
const destM = clean.match(DEST_ARROW_RE) || clean.match(DEST_WORD_RE);
|
const destM = clean.match(DEST_ARROW_RE) || clean.match(DEST_WORD_RE);
|
||||||
if (!destM) return null;
|
if (!destM) return null;
|
||||||
|
|
||||||
const host = destM[1];
|
const host = destM[1];
|
||||||
const port = parseInt(destM[2], 10);
|
const port = parseInt(destM[2], 10);
|
||||||
|
|
||||||
|
// Source IP: из корреляционной карты (новый формат) или из --> (старый формат)
|
||||||
|
let sourceIp = null;
|
||||||
|
if (connId) {
|
||||||
|
const stored = connSourceMap.get(connId);
|
||||||
|
if (stored && Date.now() - stored.at < CONN_TTL_MS) sourceIp = stored.sourceIp;
|
||||||
|
}
|
||||||
|
if (!sourceIp) {
|
||||||
|
const srcM = clean.match(SOURCE_ARROW_RE);
|
||||||
|
if (srcM) sourceIp = srcM[1];
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
outbound: outboundRaw,
|
outbound: outboundRaw,
|
||||||
category,
|
category,
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
|
sourceIp,
|
||||||
matchedRule: inheritedRule,
|
matchedRule: inheritedRule,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ function App() {
|
|||||||
onRemoveDevice={removeDevice}
|
onRemoveDevice={removeDevice}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{page === 'logs' && <LogsPage />}
|
{page === 'logs' && <LogsPage deviceRules={deviceRules} />}
|
||||||
{page === 'settings' && (
|
{page === 'settings' && (
|
||||||
<SettingsPage
|
<SettingsPage
|
||||||
state={state}
|
state={state}
|
||||||
|
|||||||
@@ -33,10 +33,22 @@ const CATEGORY_BADGE = {
|
|||||||
other: { cls: '', label: 'other' },
|
other: { cls: '', label: 'other' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getDeviceName(sourceIp, deviceRules) {
|
||||||
|
if (!sourceIp || !deviceRules?.length) return null;
|
||||||
|
for (const d of deviceRules) {
|
||||||
|
if (d.enabled === false) continue;
|
||||||
|
for (const ip of (d.sourceIps || [])) {
|
||||||
|
const plain = ip.endsWith('/32') ? ip.slice(0, -3) : ip;
|
||||||
|
if (plain === sourceIp) return d.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function groupTraffic(list, sortBy = 'time') {
|
function groupTraffic(list, sortBy = 'time') {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
for (const e of list) {
|
for (const e of list) {
|
||||||
const key = `${e.category}|${e.host}|${e.port}|${e.matchedRule || ''}`;
|
const key = `${e.sourceIp || ''}|${e.category}|${e.host}|${e.port}|${e.matchedRule || ''}`;
|
||||||
const ts = new Date(e.ts).getTime();
|
const ts = new Date(e.ts).getTime();
|
||||||
if (map.has(key)) {
|
if (map.has(key)) {
|
||||||
const g = map.get(key);
|
const g = map.get(key);
|
||||||
@@ -52,7 +64,7 @@ function groupTraffic(list, sortBy = 'time') {
|
|||||||
return arr.sort((a, b) => b._lastTs - a._lastTs);
|
return arr.sort((a, b) => b._lastTs - a._lastTs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrafficTab() {
|
function TrafficTab({ deviceRules = [] }) {
|
||||||
const [traffic, setTraffic] = useState([]);
|
const [traffic, setTraffic] = useState([]);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
const [filter, setFilter] = useState('all'); // all | direct | vpn | block
|
const [filter, setFilter] = useState('all'); // all | direct | vpn | block
|
||||||
@@ -90,7 +102,9 @@ function TrafficTab() {
|
|||||||
e.host?.toLowerCase().includes(s) ||
|
e.host?.toLowerCase().includes(s) ||
|
||||||
String(e.port || '').includes(s) ||
|
String(e.port || '').includes(s) ||
|
||||||
e.outbound?.toLowerCase().includes(s) ||
|
e.outbound?.toLowerCase().includes(s) ||
|
||||||
e.matchedRule?.toLowerCase().includes(s),
|
e.matchedRule?.toLowerCase().includes(s) ||
|
||||||
|
e.sourceIp?.toLowerCase().includes(s) ||
|
||||||
|
getDeviceName(e.sourceIp, deviceRules)?.toLowerCase().includes(s),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return grouped ? groupTraffic(list, sortBy) : list;
|
return grouped ? groupTraffic(list, sortBy) : list;
|
||||||
@@ -159,6 +173,7 @@ function TrafficTab() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: 70 }}>Время</th>
|
<th style={{ width: 70 }}>Время</th>
|
||||||
<th style={{ width: 70 }}>Туннель</th>
|
<th style={{ width: 70 }}>Туннель</th>
|
||||||
|
<th style={{ width: 110 }}>Устройство</th>
|
||||||
<th>Хост / IP</th>
|
<th>Хост / IP</th>
|
||||||
<th style={{ width: 55 }}>Порт</th>
|
<th style={{ width: 55 }}>Порт</th>
|
||||||
<th>Правило</th>
|
<th>Правило</th>
|
||||||
@@ -168,12 +183,20 @@ function TrafficTab() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{filtered.map((e, i) => {
|
{filtered.map((e, i) => {
|
||||||
const badge = CATEGORY_BADGE[e.category] || CATEGORY_BADGE.other;
|
const badge = CATEGORY_BADGE[e.category] || CATEGORY_BADGE.other;
|
||||||
|
const deviceName = getDeviceName(e.sourceIp, deviceRules);
|
||||||
return (
|
return (
|
||||||
<tr key={i} style={{ opacity: e.category === 'block' ? 0.6 : 1 }}>
|
<tr key={i} style={{ opacity: e.category === 'block' ? 0.6 : 1 }}>
|
||||||
<td className="muted text-mono" style={{ whiteSpace: 'nowrap' }}>{formatTime(e.ts)}</td>
|
<td className="muted text-mono" style={{ whiteSpace: 'nowrap' }}>{formatTime(e.ts)}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`badge ${badge.cls}`} style={{ fontSize: 11 }}>{badge.label}</span>
|
<span className={`badge ${badge.cls}`} style={{ fontSize: 11 }}>{badge.label}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="text-mono" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 110 }}>
|
||||||
|
{deviceName
|
||||||
|
? <span style={{ fontSize: 11 }}>{deviceName}</span>
|
||||||
|
: e.sourceIp
|
||||||
|
? <span className="muted" style={{ fontSize: 10 }}>{e.sourceIp}</span>
|
||||||
|
: <span className="muted" style={{ fontSize: 11 }}>—</span>}
|
||||||
|
</td>
|
||||||
<td className="text-mono" style={{ wordBreak: 'break-all' }}>{e.host || '—'}</td>
|
<td className="text-mono" style={{ wordBreak: 'break-all' }}>{e.host || '—'}</td>
|
||||||
<td className="muted text-mono">{e.port || '—'}</td>
|
<td className="muted text-mono">{e.port || '—'}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -195,7 +218,7 @@ function TrafficTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LogsPage() {
|
export function LogsPage({ deviceRules = [] }) {
|
||||||
const [tab, setTab] = useState('traffic'); // traffic | logs
|
const [tab, setTab] = useState('traffic'); // traffic | logs
|
||||||
const [entries, setEntries] = useState([]);
|
const [entries, setEntries] = useState([]);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
@@ -253,7 +276,7 @@ export function LogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'traffic' && <TrafficTab />}
|
{tab === 'traffic' && <TrafficTab deviceRules={deviceRules} />}
|
||||||
|
|
||||||
{tab === 'logs' && (
|
{tab === 'logs' && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user