Files
mitm-webserver/frontend/src/components/PacketViewer.tsx
malmert 74b961e152
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 11s
add visualizations, better scaling
2026-04-01 23:10:59 +02:00

1483 lines
44 KiB
TypeScript

// src/components/PacketViewer.tsx
import { CaretDownOutlined, CaretRightOutlined, DeleteOutlined, DownloadOutlined, EyeOutlined, SyncOutlined } from '@ant-design/icons';
import {
Button,
Card,
Col,
Row,
Segmented,
Select,
Space,
Spin,
Table,
Tag,
Tabs,
Tooltip,
Typography,
message,
theme,
} from 'antd';
import { ReactElement, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { clearPackets, fetchPackets, getPacketsWebSocketUrl } from '../api/apiClient';
import PacketInspectModal from './PacketInspectModal';
import type { PacketRow } from '../types/packets';
const { Text, Title } = Typography;
const { Option } = Select;
const DEFAULT_LIMIT = 200;
const DEFAULT_PAGE_SIZE = 25;
const MAX_PACKETS = 2000; // in-memory cap
const ARP_OPCODE_LABELS: Record<number, string> = {
0: 'Reserved',
1: 'REQUEST',
2: 'REPLY',
3: 'request Reverse',
4: 'reply Reverse',
5: 'DRARP-Request',
6: 'DRARP-Reply',
7: 'DRARP-Error',
8: 'InARP-Request',
9: 'InARP-Reply',
10: 'ARP-NAK',
11: 'MARS-Request',
12: 'MARS-Multi',
13: 'MARS-MServ',
14: 'MARS-Join',
15: 'MARS-Leave',
16: 'MARS-NAK',
17: 'MARS-Unserv',
18: 'MARS-SJoin',
19: 'MARS-SLeave',
20: 'MARS-Grouplist-Request',
21: 'MARS-Grouplist-Reply',
22: 'MARS-Redirect-Map',
23: 'MAPOS-UNARP',
24: 'OP_EXP1',
25: 'OP_EXP2',
};
const ICMP_TYPE_LABELS: Record<number, string> = {
0: 'Echo Reply',
3: 'Destination Unreachable',
4: 'Source Quench (Deprecated)',
5: 'Redirect',
6: 'Alternate Host Address (Deprecated)',
8: 'Echo',
9: 'Router Advertisement',
10: 'Router Solicitation',
11: 'Time Exceeded',
12: 'Parameter Problem',
13: 'Timestamp',
14: 'Timestamp Reply',
15: 'Information Request (Deprecated)',
16: 'Information Reply (Deprecated)',
17: 'Address Mask Request (Deprecated)',
18: 'Address Mask Reply (Deprecated)',
19: 'Reserved (for Security)',
30: 'Traceroute (Deprecated)',
31: 'Datagram Conversion Error (Deprecated)',
32: 'Mobile Host Redirect (Deprecated)',
33: 'IPv6 Where-Are-You (Deprecated)',
34: 'IPv6 I-Am-Here (Deprecated)',
35: 'Mobile Registration Request (Deprecated)',
36: 'Mobile Registration Reply (Deprecated)',
37: 'Domain Name Request (Deprecated)',
38: 'Domain Name Reply (Deprecated)',
39: 'SKIP (Deprecated)',
40: 'Photuris',
41: 'ICMP experimental mobility',
42: 'Extended Echo Request',
43: 'Extended Echo Reply',
253: 'RFC3692-style Experiment 1',
254: 'RFC3692-style Experiment 2',
255: 'Reserved',
};
const DNS_QUERY_TYPE_LABELS: Record<number, string> = {
1: 'A',
2: 'NS',
5: 'CNAME',
6: 'SOA',
12: 'PTR',
15: 'MX',
16: 'TXT',
17: 'RP',
18: 'AFSDB',
24: 'SIG',
25: 'KEY',
28: 'AAAA',
29: 'LOC',
33: 'SRV',
35: 'NAPTR',
36: 'KX',
37: 'CERT',
39: 'DNAME',
41: 'OPT',
42: 'APL',
43: 'DS',
44: 'SSHFP',
45: 'IPSECKEY',
46: 'RRSIG',
47: 'NSEC',
48: 'DNSKEY',
49: 'DHCID',
50: 'NSEC3',
51: 'NSEC3PARAM',
52: 'TLSA',
55: 'HIP',
59: 'CDS',
60: 'CDNSKEY',
61: 'OPENPGPKEY',
62: 'CSYNC',
63: 'ZONEMD',
64: 'SVCB',
65: 'HTTPS',
99: 'SPF',
108: 'EUI48',
109: 'EUI64',
249: 'TKEY',
250: 'TSIG',
251: 'IXFR',
252: 'AXFR',
255: 'ANY',
256: 'URI',
257: 'CAA',
32768: 'TA',
32769: 'DLV',
};
type PacketTableRow = PacketRow & {
key: string;
__kind: 'packet' | 'flow';
flow_packet_count?: number;
children?: PacketTableRow[];
};
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' });
}
function base64ToBytes(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 arr;
}
function formatJson(value: unknown) {
if (value == null) return '(no tshark data)';
try {
return JSON.stringify(value, null, 2);
} catch {
return '(failed to format tshark data)';
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function buildReducedMetadata(packet: PacketRow | null) {
if (!packet) return null;
const dpi = isPlainObject(packet.dpi_metadata) ? { ...packet.dpi_metadata } : null;
if (dpi && 'layers' in dpi) {
delete dpi.layers;
}
return {
dpi_metadata: dpi,
capture_metadata: packet.capture_metadata ?? null,
telemetry_metadata: packet.telemetry_metadata ?? null,
};
}
/**
* 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.correlation_key ?? packet.packet_id ?? packet.packet_uid ?? packet.id ?? `${packet.ingress_if ?? 'if'}:${packet.timestamp ?? ''}`);
}
function packetTimestampMs(packet: PacketRow) {
const time = packet.timestamp ? new Date(packet.timestamp).getTime() : 0;
return Number.isNaN(time) ? 0 : time;
}
function sortPacketsByTimestampDesc(left: PacketRow, right: PacketRow) {
return packetTimestampMs(right) - packetTimestampMs(left);
}
function sortPacketsByTimestampAsc(left: PacketRow, right: PacketRow) {
return packetTimestampMs(left) - packetTimestampMs(right);
}
function getDpiDict(packet: PacketRow, key: string): Record<string, unknown> | null {
const dpi = packet.dpi_metadata;
if (!dpi || typeof dpi !== 'object' || Array.isArray(dpi)) return null;
const value = dpi[key];
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function getStringValue(value: unknown) {
return value == null ? null : String(value);
}
function getNumberValue(value: unknown) {
return typeof value === 'number' ? value : value == null ? null : Number(value);
}
function getStringList(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.map((entry) => getStringValue(entry))
.filter((entry): entry is string => Boolean(entry));
}
const text = getStringValue(value);
if (!text) return [];
return text
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}
function isLikelyText(bytes: Uint8Array) {
if (bytes.length === 0) return false;
let printable = 0;
for (const byte of bytes) {
if (byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte <= 126)) {
printable += 1;
}
}
return printable / bytes.length >= 0.75;
}
function decodePayloadText(bytes: Uint8Array) {
try {
return new TextDecoder('utf-8', { fatal: false }).decode(bytes).replace(/\0/g, '');
} catch {
return null;
}
}
function extractTransportPayload(packet: PacketRow | null): Uint8Array | null {
if (!packet?.raw_b64) return null;
try {
const bytes = base64ToBytes(packet.raw_b64);
if (bytes.length < 14) return null;
let etherType = (bytes[12] << 8) | bytes[13];
let offset = 14;
if (etherType === 0x8100 || etherType === 0x88a8) {
if (bytes.length < 18) return null;
etherType = (bytes[16] << 8) | bytes[17];
offset = 18;
}
if (etherType === 0x0800) {
if (bytes.length < offset + 20) return null;
const ipHeaderLength = (bytes[offset] & 0x0f) * 4;
const protocol = bytes[offset + 9];
const transportOffset = offset + ipHeaderLength;
if (protocol === 6) {
if (bytes.length < transportOffset + 20) return null;
const tcpHeaderLength = ((bytes[transportOffset + 12] >> 4) & 0x0f) * 4;
const payloadOffset = transportOffset + tcpHeaderLength;
return bytes.slice(Math.min(payloadOffset, bytes.length));
}
if (protocol === 17) {
if (bytes.length < transportOffset + 8) return null;
return bytes.slice(Math.min(transportOffset + 8, bytes.length));
}
return bytes.slice(Math.min(transportOffset, bytes.length));
}
if (etherType === 0x86dd) {
if (bytes.length < offset + 40) return null;
const protocol = bytes[offset + 6];
const transportOffset = offset + 40;
if (protocol === 6) {
if (bytes.length < transportOffset + 20) return null;
const tcpHeaderLength = ((bytes[transportOffset + 12] >> 4) & 0x0f) * 4;
const payloadOffset = transportOffset + tcpHeaderLength;
return bytes.slice(Math.min(payloadOffset, bytes.length));
}
if (protocol === 17) {
if (bytes.length < transportOffset + 8) return null;
return bytes.slice(Math.min(transportOffset + 8, bytes.length));
}
return bytes.slice(Math.min(transportOffset, bytes.length));
}
return null;
} catch {
return null;
}
}
function getDecodedPayload(packet: PacketRow | null) {
const payload = extractTransportPayload(packet);
if (!payload || payload.length === 0) return null;
const text = decodePayloadText(payload);
const textPayload = text && isLikelyText(payload) ? text : null;
const http = packet ? getDpiDict(packet, 'http') : null;
if (http && textPayload) {
const separator = textPayload.includes('\r\n\r\n') ? '\r\n\r\n' : textPayload.includes('\n\n') ? '\n\n' : null;
if (separator) {
const [headerPart, bodyPart = ''] = textPayload.split(separator, 2);
return {
payloadText: textPayload,
headersText: headerPart.trim(),
bodyText: bodyPart.trim() || null,
};
}
}
return {
payloadText: textPayload,
headersText: null,
bodyText: null,
};
}
function parseHttpParts(http: Record<string, unknown>) {
const rawUri = getStringValue(http.uri);
const host = getStringValue(http.host);
if (!rawUri) {
return { path: null, queryEntries: [] as Array<[string, string]> };
}
try {
const base = rawUri.startsWith('http://') || rawUri.startsWith('https://') ? undefined : `http://${host ?? 'packet.local'}`;
const url = new URL(rawUri, base);
return {
path: `${url.pathname}${url.hash}`,
queryEntries: Array.from(url.searchParams.entries()),
};
} catch {
const [path, query = ''] = rawUri.split('?', 2);
const queryEntries = query
.split('&')
.filter(Boolean)
.map((item) => {
const [key, value = ''] = item.split('=', 2);
return [decodeURIComponent(key), decodeURIComponent(value)] as [string, string];
});
return { path: path || rawUri, queryEntries };
}
}
function renderKeyValueBlock(title: string, values: Array<[string, ReactNode]>) {
const filtered = values.filter(([, value]) => value !== null && value !== undefined && value !== '');
if (filtered.length === 0) return null;
return (
<div>
<Text strong>{title}</Text>
<Descriptions bordered size="small" column={2} style={{ marginTop: 8 }}>
{filtered.map(([label, value]) => (
<Descriptions.Item key={`${title}-${label}`} label={label}>
{value}
</Descriptions.Item>
))}
</Descriptions>
</div>
);
}
function renderTextBlock(title: string, content: string | null | undefined) {
if (!content) return null;
return (
<div>
<Text strong>{title}</Text>
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>{content}</pre>
</div>
);
}
function renderProtocolSummary(packet: PacketRow | null): ReactNode {
if (!packet) return <Text type="secondary">No packet selected.</Text>;
const http = getDpiDict(packet, 'http');
const tls = getDpiDict(packet, 'tls');
const dns = getDpiDict(packet, 'dns');
const tcp = getDpiDict(packet, 'tcp');
const udp = getDpiDict(packet, 'udp');
const icmp = getDpiDict(packet, 'icmp');
const arp = getDpiDict(packet, 'arp');
const tshark = getDpiDict(packet, 'tshark');
const httpParts = http ? parseHttpParts(http) : null;
const decodedPayload = getDecodedPayload(packet);
const sections: ReactNode[] = [];
sections.push(
renderKeyValueBlock('Packet', [
['Timestamp', formatTimestamp(packet.timestamp)],
['Flow ID', packet.flow_id ?? getFlowId(packet) ?? '-'],
['Protocol', formatProtocolLabel(packet)],
['Application', packet.app_protocol ?? packet.app_master_protocol ?? '-'],
['Source', packet.src_ip ? `${packet.src_ip}${packet.src_port ? `:${packet.src_port}` : ''}` : '-'],
['Destination', packet.dst_ip ? `${packet.dst_ip}${packet.dst_port ? `:${packet.dst_port}` : ''}` : '-'],
['Path', [packet.ingress_if, packet.egress_if].filter(Boolean).join(' -> ') || '-'],
]),
);
if (http) {
sections.push(
renderKeyValueBlock('HTTP', [
['Kind', getNumberValue(http.response_code) != null ? 'Response' : getStringValue(http.method) ? 'Request' : null],
['Method', getStringValue(http.method)],
['Host', getStringValue(http.host)],
['URL / URI', getStringValue(http.uri)],
['Path', httpParts?.path ?? null],
['Status Code', getNumberValue(http.response_code) ?? null],
['Reason', getStringValue(http.response_phrase)],
['Content Type', getStringValue(http.content_type)],
['User Agent', getStringValue(http.user_agent)],
['Server', getStringValue(http.server)],
]),
);
if (httpParts && httpParts.queryEntries.length > 0) {
sections.push(
renderKeyValueBlock(
'HTTP Query Parameters',
httpParts.queryEntries.map(([key, value]) => [key, value]),
),
);
}
}
if (decodedPayload?.headersText) {
sections.push(renderTextBlock('Decoded Headers', decodedPayload.headersText));
}
if (decodedPayload?.bodyText) {
const httpKind = getNumberValue(http?.response_code) != null ? 'Response Body' : getStringValue(http?.method) ? 'Request Body' : 'Decoded Body';
sections.push(renderTextBlock(httpKind, decodedPayload.bodyText));
} else if (decodedPayload?.payloadText) {
sections.push(renderTextBlock('Decoded Payload', decodedPayload.payloadText));
}
if (dns) {
const queryType = formatDnsQueryType(dns.query_type);
sections.push(
renderKeyValueBlock('DNS', [
['Kind', dns.is_response === true ? 'Response' : dns.is_response === false ? 'Query' : null],
['Query Name', getStringValue(dns.query_name)],
['Query Type', queryType],
['Response Name', getStringValue(dns.response_name)],
['A Record', Array.isArray(dns.a) ? dns.a.join(', ') : getStringValue(dns.a)],
['AAAA Record', Array.isArray(dns.aaaa) ? dns.aaaa.join(', ') : getStringValue(dns.aaaa)],
['CNAME', Array.isArray(dns.cname) ? dns.cname.join(', ') : getStringValue(dns.cname)],
]),
);
}
if (tls) {
sections.push(
renderKeyValueBlock('TLS', [
['Server Name', getStringValue(tls.server_name)],
['Version', getStringValue(tls.handshake_version)],
['ALPN', getStringValue(tls.alpn)],
]),
);
}
if (tcp) {
sections.push(
renderKeyValueBlock('TCP', [
['Packet Type', getStringValue(tcp.packet_type)],
['Flags', Array.isArray(tcp.flag_names) ? tcp.flag_names.join(', ') : getStringValue(tcp.flag_names)],
['Stream', getStringValue(tcp.stream)],
['Seq', getNumberValue(tcp.seq_raw) ?? null],
['Ack', getNumberValue(tcp.ack_raw) ?? null],
['Payload Length', getNumberValue(tcp.payload_len) ?? null],
['Retransmission', tcp.retransmission === true ? 'yes' : null],
['Duplicate ACK', tcp.duplicate_ack === true ? 'yes' : null],
['Keep Alive', tcp.keep_alive === true ? 'yes' : null],
]),
);
} else if (udp) {
sections.push(
renderKeyValueBlock('UDP', [
['Stream', getStringValue(udp.stream)],
]),
);
}
if (icmp) {
const icmpType = getNumberValue(icmp.type);
sections.push(
renderKeyValueBlock('ICMP', [
['Type', icmpType ?? null],
['Name', formatIcmpType(icmpType)],
['Code', getNumberValue(icmp.code) ?? null],
]),
);
}
if (arp) {
const opcode = getNumberValue(arp.opcode);
sections.push(
renderKeyValueBlock('ARP', [
['Opcode', opcode ?? null],
['Operation', formatArpOpcode(opcode)],
]),
);
}
if (tshark) {
sections.push(
renderKeyValueBlock('Dissector', [
['Wireshark Protocol', getStringValue(tshark.protocol)],
['Info', getStringValue(tshark.info)],
['Protocol Stack', Array.isArray(tshark.protocol_stack) ? tshark.protocol_stack.join(' -> ') : getStringValue(tshark.protocol_stack)],
]),
);
}
const content = sections.filter(Boolean);
return content.length > 0 ? (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
{content}
</Space>
) : (
<Text type="secondary">No decoded summary available for this packet.</Text>
);
}
function getFlowId(packet: PacketRow) {
if (packet.flow_id) return String(packet.flow_id);
const tcp = getDpiDict(packet, 'tcp');
if (tcp?.stream != null) return `tcp:${String(tcp.stream)}`;
const udp = getDpiDict(packet, 'udp');
if (udp?.stream != null) return `udp:${String(udp.stream)}`;
const tshark = getDpiDict(packet, 'tshark');
if (tshark?.tcp_stream != null) return `tcp:${String(tshark.tcp_stream)}`;
if (tshark?.udp_stream != null) return `udp:${String(tshark.udp_stream)}`;
return null;
}
function formatEthType(packet: PacketRow) {
if (packet.eth_type) return String(packet.eth_type);
if (typeof packet.eth_type_raw === 'number') return `0x${packet.eth_type_raw.toString(16)}`;
return '-';
}
function formatIpProto(packet: PacketRow) {
if (packet.ip_proto) return String(packet.ip_proto);
if (typeof packet.ip_proto_raw === 'number') return String(packet.ip_proto_raw);
return '-';
}
function formatProtocolLabel(packet: PacketRow) {
return formatIpProto(packet);
}
function formatArpOpcode(opcode: number | null) {
if (opcode == null) return null;
if (Object.prototype.hasOwnProperty.call(ARP_OPCODE_LABELS, opcode)) {
return ARP_OPCODE_LABELS[opcode];
}
if (opcode >= 26 && opcode <= 65534) {
return 'Unassigned';
}
return `Op ${opcode}`;
}
function formatIcmpType(type: number | null) {
if (type == null) return null;
if (Object.prototype.hasOwnProperty.call(ICMP_TYPE_LABELS, type)) {
return ICMP_TYPE_LABELS[type];
}
if (type === 1 || type === 2 || type === 7) {
return 'Unassigned';
}
if (type >= 20 && type <= 29) {
return 'Reserved (for Robustness Experiment)';
}
if (type >= 44 && type <= 252) {
return 'Unassigned';
}
return `Type ${type}`;
}
function formatDnsQueryType(value: unknown) {
const numeric = getNumberValue(value);
if (numeric != null) {
return DNS_QUERY_TYPE_LABELS[numeric] ?? `TYPE${numeric}`;
}
const text = getStringValue(value);
return text || null;
}
function formatAppDetails(packet: PacketRow) {
const http = getDpiDict(packet, 'http');
if (http) {
const method = getStringValue(http.method);
const statusCode = getNumberValue(http.response_code);
const reason = getStringValue(http.response_phrase);
if (method) return `Request ${method}`;
if (statusCode != null) return `Response ${statusCode}${reason ? ` ${reason}` : ''}`;
}
const dns = getDpiDict(packet, 'dns');
if (dns) {
const queryName = getStringValue(dns.query_name);
const queryType = formatDnsQueryType(dns.query_type);
if (dns.is_response === false) return `Query${queryType ? ` ${queryType}` : ''}${queryName ? ` ${queryName}` : ''}`;
if (dns.is_response === true) return `Response${queryName ? ` ${queryName}` : ''}`;
}
const tls = getDpiDict(packet, 'tls');
if (tls) {
const serverName = getStringValue(tls.server_name);
const version = getStringValue(tls.handshake_version);
if (serverName) return `SNI ${serverName}`;
if (version) return `Handshake ${version}`;
}
return packet.app_confidence ?? packet.app_category ?? '';
}
function buildProtocolBadges(packet: PacketRow): ReactNode[] {
const badges: ReactNode[] = [];
const tcp = getDpiDict(packet, 'tcp');
const tshark = getDpiDict(packet, 'tshark');
const packetType = getStringValue(tcp?.packet_type ?? tshark?.tcp_packet_type);
const flagNames = Array.from(
new Set([
...getStringList(tcp?.flag_names),
...getStringList(tshark?.tcp_flag_names),
]),
);
for (const flagName of flagNames) {
const normalized = flagName.toUpperCase();
let color = 'default';
if (normalized === 'SYN') color = 'blue';
else if (normalized === 'ACK') color = 'cyan';
else if (normalized === 'PSH') color = 'green';
else if (normalized === 'FIN') color = 'orange';
else if (normalized === 'RST') color = 'red';
else if (normalized === 'URG') color = 'volcano';
else if (normalized === 'ECE' || normalized === 'CWR') color = 'purple';
badges.push(
<Tag key={`protocol-flag-${flagName}`} color={color} style={{ marginInlineEnd: 4 }}>
{flagName}
</Tag>,
);
}
if (packetType && flagNames.length === 0) {
badges.push(
<Tag key={`protocol-type-${packetType}`} color="default" style={{ marginInlineEnd: 4 }}>
{packetType}
</Tag>,
);
}
if (tcp?.retransmission === true) {
badges.push(
<Tag key="protocol-retransmission" color="red" style={{ marginInlineEnd: 4 }}>
Retransmission
</Tag>,
);
}
if (tcp?.duplicate_ack === true) {
badges.push(
<Tag key="protocol-dup-ack" color="volcano" style={{ marginInlineEnd: 4 }}>
Dup ACK
</Tag>,
);
}
if (tcp?.keep_alive === true) {
badges.push(
<Tag key="protocol-keepalive" color="lime" style={{ marginInlineEnd: 4 }}>
Keep-Alive
</Tag>,
);
}
return badges;
}
function buildAppBadges(packet: PacketRow): ReactNode[] {
const badges: ReactNode[] = [];
const http = getDpiDict(packet, 'http');
const dns = getDpiDict(packet, 'dns');
const tls = getDpiDict(packet, 'tls');
const arp = getDpiDict(packet, 'arp');
const icmp = getDpiDict(packet, 'icmp');
if (http) {
const method = getStringValue(http.method);
const statusCode = getNumberValue(http.response_code);
if (method) {
badges.push(
<Tag key="http-request" color="green" style={{ marginInlineEnd: 4 }}>
Request
</Tag>,
);
badges.push(
<Tag key={`http-method-${method}`} color="blue" style={{ marginInlineEnd: 4 }}>
{method}
</Tag>,
);
} else if (statusCode != null) {
badges.push(
<Tag key="http-response" color="cyan" style={{ marginInlineEnd: 4 }}>
Response
</Tag>,
);
badges.push(
<Tag key={`http-status-${statusCode}`} color="geekblue" style={{ marginInlineEnd: 4 }}>
{statusCode}
</Tag>,
);
}
}
if (dns) {
const queryType = formatDnsQueryType(dns.query_type);
if (dns.is_response === false) {
badges.push(
<Tag key="dns-query" color="purple" style={{ marginInlineEnd: 4 }}>
DNS Query
</Tag>,
);
} else if (dns.is_response === true) {
badges.push(
<Tag key="dns-response" color="magenta" style={{ marginInlineEnd: 4 }}>
DNS Response
</Tag>,
);
}
if (queryType) {
badges.push(
<Tag key={`dns-type-${queryType}`} color="processing" style={{ marginInlineEnd: 4 }}>
{queryType}
</Tag>,
);
}
}
if (tls) {
const version = getStringValue(tls.handshake_version);
const alpn = getStringValue(tls.alpn);
if (version) {
badges.push(
<Tag key={`tls-version-${version}`} color="gold" style={{ marginInlineEnd: 4 }}>
{version}
</Tag>,
);
}
if (alpn) {
badges.push(
<Tag key={`tls-alpn-${alpn}`} color="orange" style={{ marginInlineEnd: 4 }}>
{alpn}
</Tag>,
);
}
}
if (arp) {
const opcode = getNumberValue(arp.opcode);
const label = formatArpOpcode(opcode);
if (opcode != null && label) {
const color =
opcode === 1 ? 'geekblue' :
opcode === 2 ? 'cyan' :
opcode === 10 || opcode === 16 ? 'volcano' :
opcode === 24 || opcode === 25 ? 'purple' :
'default';
badges.push(
<Tag key={`arp-op-${opcode}`} color={color} style={{ marginInlineEnd: 4 }}>
{label}
</Tag>,
);
}
}
if (icmp) {
const icmpType = getNumberValue(icmp.type);
const label = formatIcmpType(icmpType);
if (icmpType != null && label) {
const color =
icmpType === 0 || icmpType === 8 ? 'blue' :
icmpType === 3 || icmpType === 11 || icmpType === 12 ? 'volcano' :
icmpType === 5 ? 'gold' :
icmpType === 42 || icmpType === 43 ? 'cyan' :
'default';
badges.push(
<Tag key={`icmp-type-${icmpType}`} color={color} style={{ marginInlineEnd: 4 }}>
{label}
</Tag>,
);
}
}
if (badges.length === 0) {
const fallback = formatAppDetails(packet);
if (fallback) {
badges.push(
<Text key="fallback-detail" type="secondary" style={{ fontSize: 12 }}>
{fallback}
</Text>,
);
}
}
return badges;
}
/** 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 { token } = theme.useToken();
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 [mode, setMode] = useState<'live' | 'history'>('live');
const [pageSize, setPageSize] = useState<number>(DEFAULT_PAGE_SIZE);
const [paused, setPaused] = useState<boolean>(false);
const wsRef = useRef<WebSocket | null>(null);
const [hexModalOpen, setHexModalOpen] = useState(false);
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) => {
return sortPacketsByTimestampDesc(left, right);
});
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].sort(sortPacketsByTimestampDesc));
} catch (err: any) {
console.error('fetchHistory error', err);
message.error(err?.message ?? 'Failed to fetch packet history');
} finally {
setStatusLoading(false);
setLoading(false);
}
}, []);
const closeWs = useCallback(() => {
if (wsRef.current) {
try {
wsRef.current.close();
} catch {}
wsRef.current = null;
}
}, []);
// open websocket
const openWs = useCallback((subscribeRecent: number) => {
closeWs();
const wsUrl = getPacketsWebSocketUrl(subscribeRecent);
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.packet_uid || 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;
};
}, [closeWs, 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 / mode change: fetch history, optionally attach to live websocket
useEffect(() => {
setLoading(true);
fetchHistory(limit).then(() => {
if (mode === 'live') {
openWs(limit);
} else {
closeWs();
}
});
return () => {
closeWs();
};
}, [closeWs, fetchHistory, limit, mode, openWs]);
const handleRefresh = async () => {
setLoading(true);
try {
await fetchHistory(limit);
if (mode === 'live') {
openWs(limit);
}
} finally {
setLoading(false);
}
};
const handlePauseToggle = (val: boolean) => {
setPaused(val);
};
const openRawModal = (row: PacketRow) => {
if (!row.raw_b64 && !row.dpi_metadata) {
message.info('No inspect data available for this packet');
return;
}
setSelectedPacket(row);
setHexModalOpen(true);
};
const tableRows = useMemo<PacketTableRow[]>(() => {
const grouped = new Map<string, PacketRow[]>();
const standalone: PacketTableRow[] = [];
for (const packet of packets) {
const flowId = getFlowId(packet);
if (!flowId) {
standalone.push({ ...packet, key: packetKey(packet), __kind: 'packet' });
continue;
}
const items = grouped.get(flowId);
if (items) {
items.push(packet);
} else {
grouped.set(flowId, [packet]);
}
}
const flowRows: PacketTableRow[] = [];
for (const [flowId, flowPackets] of grouped.entries()) {
const sortedPackets = [...flowPackets].sort(sortPacketsByTimestampDesc);
if (sortedPackets.length === 1) {
flowRows.push({
...sortedPackets[0],
flow_id: flowId,
key: packetKey(sortedPackets[0]),
__kind: 'packet',
});
continue;
}
const latestPacket = [...flowPackets].sort(sortPacketsByTimestampDesc)[0];
const children = sortedPackets.map((packet) => ({
...packet,
flow_id: flowId,
key: packetKey(packet),
__kind: 'packet' as const,
}));
flowRows.push({
...latestPacket,
flow_id: flowId,
key: `flow:${flowId}`,
__kind: 'flow',
flow_packet_count: sortedPackets.length,
children,
});
}
return [...flowRows, ...standalone].sort(sortPacketsByTimestampDesc);
}, [packets]);
const columns = useMemo(
() => [
{
title: 'Group',
key: 'group',
width: 130,
render: (_: any, rec: PacketTableRow) =>
rec.__kind === 'flow' ? (
<Space direction="vertical" size={0}>
<Tag color="blue" style={{ width: 'fit-content', marginInlineEnd: 0 }}>
Flow
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{rec.flow_packet_count ?? 0} packets
</Text>
</Space>
) : (
<Text type="secondary" style={{ paddingLeft: 18 }}>
Packet
</Text>
),
},
{
title: 'Time',
dataIndex: 'timestamp',
key: 'timestamp',
width: 120,
render: (val: any) => <Text>{formatTimestamp(val)}</Text>,
},
{
title: 'Path',
key: 'path',
width: 150,
render: (_: any, rec: PacketRow) => (
<Space wrap size={[6, 6]}>
{[rec.ingress_if, rec.egress_if].filter(Boolean).length > 0 ? (
<>
{rec.ingress_if && (
<Tooltip title={`ingress: ${rec.ingress_if}`}>
<Tag style={{ ...colorForName(rec.ingress_if), fontWeight: 600, borderRadius: 6, fontSize: 15 }}>
{rec.ingress_if}
</Tag>
</Tooltip>
)}
{rec.egress_if && (
<Tooltip title={`egress: ${rec.egress_if}`}>
<Tag style={{ ...colorForName(rec.egress_if), fontWeight: 600, borderRadius: 6, fontSize: 15 }}>
{rec.egress_if}
</Tag>
</Tooltip>
)}
</>
) : (
<Text type="secondary">-</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: 'L2',
key: 'eth_type',
width: 100,
render: (_: any, rec: PacketRow) => <Text>{formatEthType(rec)}</Text>,
},
{
title: 'Protocol',
dataIndex: 'ip_proto',
key: 'ip_proto',
width: 130,
render: (_: any, rec: PacketTableRow) => (
<Space direction="vertical" size={0}>
<Text>{rec.__kind === 'flow' ? `${formatIpProto(rec)} flow` : formatProtocolLabel(rec)}</Text>
{rec.__kind === 'packet' ? <Space size={[0, 4]} wrap>{buildProtocolBadges(rec)}</Space> : null}
</Space>
),
},
{
title: 'App',
key: 'app',
width: 250,
render: (_: any, rec: PacketRow) => {
const app = rec.app_protocol ?? rec.app_master_protocol ?? '-';
return (
<Space direction="vertical" size={0}>
<Text>{app}</Text>
<Space size={[0, 4]} wrap>
{buildAppBadges(rec)}
</Space>
</Space>
);
},
},
{
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/Pkts',
dataIndex: 'length',
key: 'length',
width: 90,
render: (_: any, rec: PacketTableRow) =>
rec.__kind === 'flow'
? `${rec.flow_packet_count ?? 0} pkts`
: typeof rec.length === 'number'
? rec.length.toLocaleString('de-DE')
: '-',
},
{
title: 'Actions',
key: 'actions',
width: 96,
render: (_: any, rec: PacketTableRow) =>
rec.__kind === 'flow' ? null : (
<Space>
<Tooltip title="View raw + tshark">
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => openRawModal(rec)}
aria-label="Inspect packet"
/>
</Tooltip>
{rec.raw_b64 && (
<Tooltip title="Download raw">
<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);
}}
aria-label="Download raw packet"
/>
</Tooltip>
)}
</Space>
),
},
],
[],
);
return (
<div className="packet-viewer">
<style>
{`
.packet-viewer .ant-table-tbody > tr.packet-flow-row > td {
background: #eef6ff;
border-top: 1px solid #cfe2ff;
border-bottom: 1px solid #cfe2ff;
}
.packet-viewer .ant-table-tbody > tr.packet-child-row > td {
background: #fafcff;
}
.packet-viewer .ant-table-tbody > tr.packet-child-row > td:first-child {
border-left: 3px solid #d6e4ff;
}
.packet-viewer .ant-table-row-expand-icon {
border-radius: 999px;
}
.packet-viewer .packet-flow-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
}
.packet-viewer .packet-mode-toggle.ant-segmented {
background: ${token.colorFillTertiary};
}
.packet-viewer .packet-mode-toggle .ant-segmented-item-selected {
background: ${token.colorPrimary};
color: ${token.colorTextLightSolid};
}
.packet-viewer .packet-mode-toggle .ant-segmented-item-selected:hover {
color: ${token.colorTextLightSolid};
}
`}
</style>
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
<Col>
<Title level={3}>Packets</Title>
<div>
<Text type="secondary">
{mode === 'live' ? 'Live packet viewer via websocket stream' : 'Historical packet viewer from database'}
</Text>
</div>
</Col>
<Col>
<Space>
<Segmented
className="packet-mode-toggle"
value={mode}
onChange={(value) => setMode(value as 'live' | 'history')}
options={[
{ label: 'Live packets', value: 'live' },
{ label: 'History', value: 'history' },
]}
/>
<Text>{mode === 'live' ? 'Recent buffer' : 'History size'}</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>Page size</Text>
<Select value={pageSize} onChange={(v) => setPageSize(Number(v))} style={{ width: 120 }}>
<Option value={25}>25</Option>
<Option value={50}>50</Option>
<Option value={100}>100</Option>
<Option value={200}>200</Option>
</Select>
{mode === 'live' ? (
<Button onClick={() => handlePauseToggle(!paused)}>
{paused ? 'Resume live' : 'Pause live'}
</Button>
) : null}
<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: PacketTableRow) => rec.key}
dataSource={tableRows}
columns={columns as any}
pagination={{
pageSize,
showSizeChanger: false,
}}
size="small"
bordered
expandable={{
expandIconColumnIndex: 0,
columnWidth: 42,
defaultExpandAllRows: true,
rowExpandable: (record: PacketTableRow) => Array.isArray(record.children) && record.children.length > 0,
expandIcon: ({ expanded, onExpand, record }) =>
Array.isArray((record as PacketTableRow).children) && (record as PacketTableRow).children!.length > 0 ? (
<Button
type="text"
size="small"
icon={expanded ? <CaretDownOutlined /> : <CaretRightOutlined />}
onClick={(event) => onExpand(record, event)}
aria-label={expanded ? 'Collapse flow' : 'Expand flow'}
/>
) : (
<span style={{ display: 'inline-block', width: 24 }} />
),
}}
rowClassName={(record: PacketTableRow) => (record.__kind === 'flow' ? 'packet-flow-row' : 'packet-child-row')}
/>
)}
</Card>
<PacketInspectModal
packet={selectedPacket}
open={hexModalOpen}
onClose={() => {
setHexModalOpen(false);
setSelectedPacket(null);
}}
/>
</div>
);
}