Files
mitm-webserver/frontend/src/components/PacketViewer.tsx
malmert f9bbe9b73c
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m40s
test ebpf
2026-03-07 10:06:24 +01:00

550 lines
16 KiB
TypeScript

// src/components/PacketViewer.tsx
import { DeleteOutlined, DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import {
Button,
Card,
Col,
Modal,
Row,
Select,
Space,
Spin,
Switch,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd';
import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { clearPackets, fetchPackets } from '../api/apiClient';
import type { PacketRow } from '../types/packets';
const { Text, Title } = Typography;
const { Option } = Select;
const DEFAULT_LIMIT = 200;
const MAX_PACKETS = 2000; // in-memory cap
function base64ToHex(b64: string) {
try {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join(' ');
} catch {
return '(invalid base64)';
}
}
function base64ToBlob(b64: string) {
const bin = atob(b64);
const len = bin.length;
const arr = new Uint8Array(len);
for (let i = 0; i < len; i++) arr[i] = bin.charCodeAt(i);
return new Blob([arr.buffer], { type: 'application/octet-stream' });
}
/**
* Improved deterministic color generation
*
* - returns { background: hsl(...), color: '#000' | '#fff' }
* - uses cached Map so results are stable/fast
*/
const _colorCache = new Map<string, { background: string; color: string }>();
function colorForName(name: string) {
const key = String(name);
const cached = _colorCache.get(key);
if (cached) return cached;
// simple hash to produce a hue in [0, 359]
let h = 2166136261 >>> 0; // FNV offset basis
for (let i = 0; i < key.length; i++) {
h ^= key.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0; // FNV prime
}
const hue = h % 360;
// Choose saturation and lightness to ensure good contrast
// We vary lightness slightly by name so colors differ more
const sat = 68; // %
// map part of hash to lightness in range [38, 58]
const light = 38 + (h % 21); // 38..58
// Use comma syntax for compatibility
const background = `hsl(${hue}, ${sat}%, ${light}%)`;
// Choose text color for contrast: use white for darker backgrounds
const textColor = light < 50 ? '#ffffff' : '#0b0b0b';
const result = { background, color: textColor };
_colorCache.set(key, result);
return result;
}
function packetKey(packet: PacketRow) {
return String(packet.packet_uid ?? packet.id ?? `${packet.iface ?? 'if'}:${packet.timestamp ?? ''}`);
}
function parseIfaces(ifaceField?: string | string[] | null, observedIfaces?: string[] | null) {
if (Array.isArray(observedIfaces) && observedIfaces.length > 0) return observedIfaces;
if (!ifaceField) return [];
if (Array.isArray(ifaceField)) return ifaceField;
return String(ifaceField)
.split(/[,\|;]+/)
.map((s) => s.trim())
.filter(Boolean);
}
/** Format timestamp to European locale with ms */
function formatTimestamp(ts?: string) {
if (!ts) return '-';
try {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return String(ts);
return (
d.toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}) + `.${String(d.getMilliseconds()).padStart(3, '0')}`
);
} catch {
return String(ts);
}
}
/**
* PacketViewer component
*/
export default function PacketViewer(): ReactElement {
const [packets, setPackets] = useState<PacketRow[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [statusLoading, setStatusLoading] = useState<boolean>(false);
const [limit, setLimit] = useState<number>(DEFAULT_LIMIT);
const [paused, setPaused] = useState<boolean>(false);
const wsRef = useRef<WebSocket | null>(null);
const [hexModalOpen, setHexModalOpen] = useState(false);
const [rawHex, setRawHex] = useState<string | null>(null);
const [rawB64ForDownload, setRawB64ForDownload] = useState<string | null>(null);
const [selectedPacket, setSelectedPacket] = useState<PacketRow | null>(null);
const queuedDuringPause = useRef<PacketRow[]>([]);
const pushNew = useCallback((newPkts: PacketRow[] | PacketRow) => {
setPackets((prev) => {
const arr = Array.isArray(newPkts) ? newPkts : [newPkts];
const next = [...prev];
for (const packet of arr) {
const key = packetKey(packet);
const index = next.findIndex((item) => packetKey(item) === key);
if (index >= 0) {
next[index] = { ...next[index], ...packet };
} else {
next.unshift(packet);
}
}
next.sort((left, right) => {
const leftId = Number(left.id ?? 0);
const rightId = Number(right.id ?? 0);
if (!Number.isNaN(leftId) && !Number.isNaN(rightId) && leftId !== rightId) {
return rightId - leftId;
}
return String(right.timestamp ?? '').localeCompare(String(left.timestamp ?? ''));
});
if (next.length > MAX_PACKETS) return next.slice(0, MAX_PACKETS);
return next;
});
}, []);
// fetch historical packets
const fetchHistory = useCallback(async (limitVal: number) => {
setStatusLoading(true);
try {
const res = await fetchPackets(limitVal);
const list: PacketRow[] = res.packets ?? [];
setPackets(list);
} catch (err: any) {
console.error('fetchHistory error', err);
message.error(err?.message ?? 'Failed to fetch packet history');
} finally {
setStatusLoading(false);
setLoading(false);
}
}, []);
// open websocket
const openWs = useCallback(() => {
if (wsRef.current) {
try {
wsRef.current.close();
} catch {}
wsRef.current = null;
}
const loc = window.location;
const protocol = loc.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = `ws://mitm.lan/api/packets/ws/packets?subscribe_recent=20`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {};
ws.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data);
if (data && data.type === 'recent' && Array.isArray(data.packets)) {
if (paused) {
queuedDuringPause.current.unshift(...(data.packets as PacketRow[]));
} else {
pushNew(data.packets as PacketRow[]);
}
} else if (data && (data.iface || data.raw_b64 || data.id || data.timestamp)) {
if (paused) {
queuedDuringPause.current.unshift(data as PacketRow);
} else {
pushNew(data as PacketRow);
}
} else {
console.debug('ws unknown message', data);
}
} catch (err) {
console.error('ws message parse error', err);
}
};
ws.onerror = (e) => {
console.error('ws error', e);
};
ws.onclose = () => {
wsRef.current = null;
};
}, [paused, pushNew]);
// pause handling: when unpausing, flush queuedDuringPause into list
useEffect(() => {
if (!paused) {
const queued = queuedDuringPause.current;
if (queued.length > 0) {
pushNew(queued);
queuedDuringPause.current = [];
}
}
}, [paused, pushNew]);
// start up: fetch history and open ws
useEffect(() => {
setLoading(true);
fetchHistory(limit).then(() => {
openWs();
});
return () => {
if (wsRef.current) {
try {
wsRef.current.close();
} catch {}
wsRef.current = null;
}
};
}, [fetchHistory, limit, openWs]);
const handleRefresh = async () => {
setLoading(true);
try {
await fetchHistory(limit);
} finally {
setLoading(false);
}
};
const handlePauseToggle = (val: boolean) => {
setPaused(val);
};
const openRawModal = (row: PacketRow) => {
if (!row.raw_b64) {
message.info('No raw bytes available for this packet');
return;
}
setRawHex(base64ToHex(row.raw_b64));
setRawB64ForDownload(row.raw_b64 ?? null);
setSelectedPacket(row);
setHexModalOpen(true);
};
const downloadRaw = () => {
if (!rawB64ForDownload) return;
const blob = base64ToBlob(rawB64ForDownload);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `packet_${selectedPacket?.id ?? 'pkt'}.bin`;
a.click();
URL.revokeObjectURL(url);
};
const columns = useMemo(
() => [
{
title: 'Time',
dataIndex: 'timestamp',
key: 'timestamp',
width: 120,
render: (val: any) => <Text>{formatTimestamp(val)}</Text>,
},
{
title: 'Ifaces',
dataIndex: 'iface',
key: 'iface',
width: 100,
render: (_: any, rec: PacketRow) => {
const ifaces = parseIfaces(rec.iface, rec.observed_ifaces);
if (ifaces.length === 0) return <Text type="secondary">-</Text>;
return (
<Space wrap size={[6, 6]}>
{ifaces.map((name) => {
const { background, color } = colorForName(name);
// tag style: use background and computed text color for accessibility
return (
<Tooltip key={name} title={name}>
<Tag style={{ background, color, fontWeight: 600, borderRadius: 6, fontSize: 15 }}>{name}</Tag>
</Tooltip>
);
})}
</Space>
);
},
},
{
title: 'Path',
key: 'path',
width: 150,
render: (_: any, rec: PacketRow) => (
<Space direction="vertical" size={0}>
<Text>{rec.ingress_if ?? '-'}</Text>
<Text type="secondary">{rec.egress_if ?? '-'}</Text>
</Space>
),
},
{
title: 'Verdict',
key: 'verdict',
width: 120,
render: (_: any, rec: PacketRow) => {
const verdict = rec.verdict ?? '-';
const confidence = rec.verdict_confidence ? ` (${rec.verdict_confidence})` : '';
return (
<Space direction="vertical" size={0}>
<Text>{verdict}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{(rec.verdict_reason ?? '') + confidence}
</Text>
</Space>
);
},
},
{
title: 'MAC (src → dst)',
key: 'macs',
width: 180,
render: (_: any, rec: PacketRow) => (
<div>
<div>
<Text>{rec.src_mac ?? '—'}</Text>
</div>
<div style={{ marginTop: 6 }}>
<Text>{rec.dst_mac ?? '—'}</Text>
</div>
</div>
),
},
{
title: 'Protocol',
dataIndex: 'ip_proto',
key: 'ip_proto',
width: 110,
render: (v: any) => <Text>{v ?? '-'}</Text>,
},
{
title: 'App',
key: 'app',
width: 170,
render: (_: any, rec: PacketRow) => {
const app = rec.app_protocol ?? rec.app_master_protocol ?? '-';
return (
<Space direction="vertical" size={0}>
<Text>{app}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{rec.app_confidence ?? rec.app_category ?? ''}
</Text>
</Space>
);
},
},
{
title: 'Host',
dataIndex: 'app_hostname',
key: 'app_hostname',
width: 180,
render: (v: any) => <Text>{v ?? '-'}</Text>,
},
{
title: 'Src IP:Port',
key: 'src',
width: 180,
render: (_: any, rec: PacketRow) =>
rec.src_ip ? (
<Text copyable={{ text: `${rec.src_ip}${rec.src_port ? `:${rec.src_port}` : ''}` }}>
{rec.src_ip}
{rec.src_port ? `:${rec.src_port}` : ''}
</Text>
) : (
<Text type="secondary">-</Text>
),
},
{
title: 'Dst IP:Port',
key: 'dst',
width: 180,
render: (_: any, rec: PacketRow) =>
rec.dst_ip ? (
<Text copyable={{ text: `${rec.dst_ip}${rec.dst_port ? `:${rec.dst_port}` : ''}` }}>
{rec.dst_ip}
{rec.dst_port ? `:${rec.dst_port}` : ''}
</Text>
) : (
<Text type="secondary">-</Text>
),
},
{
title: 'Len',
dataIndex: 'length',
key: 'length',
width: 80,
render: (n: any) => (typeof n === 'number' ? n.toLocaleString('de-DE') : '-'),
},
{
title: 'Risk',
dataIndex: 'app_risk_score',
key: 'app_risk_score',
width: 70,
render: (n: any) => (typeof n === 'number' ? n : '-'),
},
{
title: 'Actions',
key: 'actions',
width: 180,
render: (_: any, rec: PacketRow) => (
<Space>
<Button size="small" onClick={() => openRawModal(rec)}>
View raw
</Button>
{rec.raw_b64 && (
<Button
size="small"
icon={<DownloadOutlined />}
onClick={() => {
const blob = base64ToBlob(rec.raw_b64!);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `packet_${rec.id ?? 'pkt'}.bin`;
a.click();
URL.revokeObjectURL(url);
}}
>
Download
</Button>
)}
</Space>
),
},
],
[],
);
return (
<div>
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
<Col>
<Title level={3}>Packets</Title>
<div>
<Text type="secondary">Live packet viewer — history + live stream</Text>
</div>
</Col>
<Col>
<Space>
<Text>History</Text>
<Select value={limit} onChange={(v) => setLimit(Number(v))} style={{ width: 120 }}>
<Option value={50}>50</Option>
<Option value={100}>100</Option>
<Option value={200}>200</Option>
<Option value={500}>500</Option>
</Select>
<Text>Live</Text>
<Switch checked={!paused} onChange={(checked) => handlePauseToggle(!checked ? true : false)} />
<Button icon={<SyncOutlined />} onClick={handleRefresh} loading={statusLoading} />
<Button
onClick={async () => {
await clearPackets();
setPackets([]);
}}
danger
icon={<DeleteOutlined />}
/>
</Space>
</Col>
</Row>
<Card>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<Spin />
</div>
) : packets.length === 0 ? (
<div style={{ padding: 12 }}>
<Text type="secondary">No packets captured yet.</Text>
</div>
) : (
<Table
rowKey={(rec: PacketRow) => packetKey(rec)}
dataSource={packets}
columns={columns as any}
pagination={{ pageSize: 25 }}
size="small"
bordered
/>
)}
</Card>
<Modal
title={`Raw packet ${selectedPacket?.id ?? ''}`}
open={hexModalOpen}
onCancel={() => setHexModalOpen(false)}
footer={
<Space>
<Button onClick={() => setHexModalOpen(false)}>Close</Button>
<Button icon={<DownloadOutlined />} onClick={downloadRaw} type="primary">
Download raw
</Button>
</Space>
}
width={900}
>
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12 }}>{rawHex}</pre>
</Modal>
</div>
);
}