diff --git a/backend/src/network_sniffer.py b/backend/src/network_sniffer.py index 7b05cda..c278761 100644 --- a/backend/src/network_sniffer.py +++ b/backend/src/network_sniffer.py @@ -7,6 +7,7 @@ import socket import selectors import errno import struct +from datetime import datetime, timezone from typing import Dict, List, Optional, Any, TypedDict, Union from uuid import uuid4 @@ -61,6 +62,7 @@ sessions: Dict[str, Dict[str, Any]] = {} class PacketInfo(TypedDict, total=False): """TypedDict for parsed packet data used by persistence and telemetry.""" + timestamp: datetime correlation_key: str correlation_source: str packet_id: Optional[str] @@ -162,6 +164,16 @@ def _safe_get_attr(layer, attr: str): return None +def _packet_timestamp(pkt: Any) -> datetime: + try: + packet_time = float(getattr(pkt, "time", 0.0) or 0.0) + if packet_time > 0: + return datetime.fromtimestamp(packet_time, tz=timezone.utc) + except Exception: + pass + return datetime.now(timezone.utc) + + def _merge_enrichment(pkt_info: PacketInfo, enrichment: Dict[str, Any]) -> None: """Populate enrichment fields without discarding existing metadata.""" for key, value in enrichment.items(): @@ -199,6 +211,7 @@ def parse_packet(pkt, bridge_label: str, capture_metadata: Optional[Dict[str, An logger.debug("Packet captured on %s (bridge_label %s)", pkt_iface, bridge_label) pkt_info: PacketInfo = { + "timestamp": _packet_timestamp(pkt), "iface": pkt_iface, "capture_iface": None, "length": len(pkt), diff --git a/backend/src/utilities/database.py b/backend/src/utilities/database.py index 88cb758..27dd35e 100644 --- a/backend/src/utilities/database.py +++ b/backend/src/utilities/database.py @@ -156,6 +156,7 @@ class DatabasePool: row = await conn.fetchrow( """ INSERT INTO packets ( + timestamp, correlation_key, packet_id, packet_uid, @@ -197,11 +198,16 @@ class DatabasePool: raw ) VALUES( $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20, - $21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36::jsonb, - $37::jsonb,$38::jsonb,$39 + $21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37::jsonb, + $38::jsonb,$39::jsonb,$40 ) ON CONFLICT (correlation_key) DO UPDATE SET updated_at = NOW(), + timestamp = CASE + WHEN packets.timestamp IS NULL THEN EXCLUDED.timestamp + WHEN EXCLUDED.timestamp IS NULL THEN packets.timestamp + ELSE LEAST(packets.timestamp, EXCLUDED.timestamp) + END, packet_id = COALESCE(EXCLUDED.packet_id, packets.packet_id), packet_uid = COALESCE(EXCLUDED.packet_uid, packets.packet_uid), correlation_source = COALESCE(EXCLUDED.correlation_source, packets.correlation_source), @@ -250,6 +256,7 @@ class DatabasePool: raw = COALESCE(EXCLUDED.raw, packets.raw) RETURNING * """, + pkt_info.get("timestamp"), pkt_info["correlation_key"], pkt_info.get("packet_id"), pkt_info.get("packet_uid"), diff --git a/backend/src/utilities/packet_tracker.py b/backend/src/utilities/packet_tracker.py index 3dbb6d7..27db42e 100644 --- a/backend/src/utilities/packet_tracker.py +++ b/backend/src/utilities/packet_tracker.py @@ -175,6 +175,7 @@ class PacketTracker: return { "correlation_key": correlation_key, "payload": { + "timestamp": None, "correlation_key": correlation_key, "correlation_source": None, "packet_id": None, @@ -239,6 +240,12 @@ class PacketTracker: continue if value is None: continue + if key == "timestamp": + current_ts = payload.get("timestamp") + if current_ts is None or value < current_ts: + payload["timestamp"] = value + changed = True + continue if key == "raw" and payload.get("raw") is not None: continue if payload.get(key) == value: diff --git a/frontend/src/components/PacketViewer.tsx b/frontend/src/components/PacketViewer.tsx index d62b51a..2421f36 100644 --- a/frontend/src/components/PacketViewer.tsx +++ b/frontend/src/components/PacketViewer.tsx @@ -1,9 +1,10 @@ // src/components/PacketViewer.tsx -import { DeleteOutlined, DownloadOutlined, EyeOutlined, SyncOutlined } from '@ant-design/icons'; +import { CaretDownOutlined, CaretRightOutlined, DeleteOutlined, DownloadOutlined, EyeOutlined, SyncOutlined } from '@ant-design/icons'; import { Button, Card, Col, + Descriptions, Modal, Row, Select, @@ -12,11 +13,12 @@ import { Switch, Table, Tag, + Tabs, Tooltip, Typography, message, } from 'antd'; -import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ReactElement, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { clearPackets, fetchPackets } from '../api/apiClient'; import type { PacketRow } from '../types/packets'; @@ -26,6 +28,125 @@ const { Option } = Select; const DEFAULT_LIMIT = 200; const MAX_PACKETS = 2000; // in-memory cap +const ARP_OPCODE_LABELS: Record = { + 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 = { + 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 = { + 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'; @@ -54,6 +175,14 @@ function base64ToBlob(b64: string) { 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 { @@ -63,6 +192,23 @@ function formatJson(value: unknown) { } } +function isPlainObject(value: unknown): value is Record { + 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 * @@ -125,6 +271,339 @@ function getDpiDict(packet: PacketRow, key: string): Record | n return value as Record; } +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) { + 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 ( +
+ {title} + + {filtered.map(([label, value]) => ( + + {value} + + ))} + +
+ ); +} + +function renderTextBlock(title: string, content: string | null | undefined) { + if (!content) return null; + return ( +
+ {title} +
{content}
+
+ ); +} + +function renderProtocolSummary(packet: PacketRow | null): ReactNode { + if (!packet) return No packet selected.; + + 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 ? ( + + {content} + + ) : ( + No decoded summary available for this packet. + ); +} + function getFlowId(packet: PacketRow) { if (packet.flow_id) return String(packet.flow_id); const tcp = getDpiDict(packet, 'tcp'); @@ -150,11 +629,265 @@ function formatIpProto(packet: PacketRow) { } function formatProtocolLabel(packet: PacketRow) { - const base = formatIpProto(packet); + 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 = tcp?.packet_type ?? tshark?.tcp_packet_type; - return packetType ? `${base} ${String(packetType)}` : base; + 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( + + {flagName} + , + ); + } + + if (packetType && flagNames.length === 0) { + badges.push( + + {packetType} + , + ); + } + if (tcp?.retransmission === true) { + badges.push( + + Retransmission + , + ); + } + if (tcp?.duplicate_ack === true) { + badges.push( + + Dup ACK + , + ); + } + if (tcp?.keep_alive === true) { + badges.push( + + Keep-Alive + , + ); + } + + 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( + + Request + , + ); + badges.push( + + {method} + , + ); + } else if (statusCode != null) { + badges.push( + + Response + , + ); + badges.push( + + {statusCode} + , + ); + } + } + + if (dns) { + const queryType = formatDnsQueryType(dns.query_type); + if (dns.is_response === false) { + badges.push( + + DNS Query + , + ); + } else if (dns.is_response === true) { + badges.push( + + DNS Response + , + ); + } + if (queryType) { + badges.push( + + {queryType} + , + ); + } + } + + if (tls) { + const version = getStringValue(tls.handshake_version); + const alpn = getStringValue(tls.alpn); + if (version) { + badges.push( + + {version} + , + ); + } + if (alpn) { + badges.push( + + {alpn} + , + ); + } + } + + 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( + + {label} + , + ); + } + } + + 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( + + {label} + , + ); + } + } + + if (badges.length === 0) { + const fallback = formatAppDetails(packet); + if (fallback) { + badges.push( + + {fallback} + , + ); + } + } + + return badges; } /** Format timestamp to European locale with ms */ @@ -190,7 +923,6 @@ export default function PacketViewer(): ReactElement { const wsRef = useRef(null); const [hexModalOpen, setHexModalOpen] = useState(false); const [rawHex, setRawHex] = useState(null); - const [rawTshark, setRawTshark] = useState(null); const [rawB64ForDownload, setRawB64ForDownload] = useState(null); const [selectedPacket, setSelectedPacket] = useState(null); const queuedDuringPause = useRef([]); @@ -325,12 +1057,11 @@ export default function PacketViewer(): ReactElement { }; const openRawModal = (row: PacketRow) => { - if (!row.raw_b64) { - message.info('No raw bytes available for this packet'); + if (!row.raw_b64 && !row.dpi_metadata) { + message.info('No inspect data available for this packet'); return; } - setRawHex(base64ToHex(row.raw_b64)); - setRawTshark(formatJson(row.dpi_metadata ?? null)); + setRawHex(row.raw_b64 ? base64ToHex(row.raw_b64) : '(no raw bytes available)'); setRawB64ForDownload(row.raw_b64 ?? null); setSelectedPacket(row); setHexModalOpen(true); @@ -347,6 +1078,10 @@ export default function PacketViewer(): ReactElement { URL.revokeObjectURL(url); }; + const reducedMetadata = useMemo(() => buildReducedMetadata(selectedPacket), [selectedPacket]); + const fullPacketJson = useMemo(() => formatJson(selectedPacket), [selectedPacket]); + const decodedPayload = useMemo(() => getDecodedPayload(selectedPacket), [selectedPacket]); + const tableRows = useMemo(() => { const grouped = new Map(); const standalone: PacketTableRow[] = []; @@ -367,7 +1102,7 @@ export default function PacketViewer(): ReactElement { const flowRows: PacketTableRow[] = []; for (const [flowId, flowPackets] of grouped.entries()) { - const sortedPackets = [...flowPackets].sort(sortPacketsByTimestampAsc); + const sortedPackets = [...flowPackets].sort(sortPacketsByTimestampDesc); if (sortedPackets.length === 1) { flowRows.push({ ...sortedPackets[0], @@ -400,6 +1135,26 @@ export default function PacketViewer(): ReactElement { const columns = useMemo( () => [ + { + title: 'Group', + key: 'group', + width: 130, + render: (_: any, rec: PacketTableRow) => + rec.__kind === 'flow' ? ( + + + Flow + + + {rec.flow_packet_count ?? 0} packets + + + ) : ( + + Packet + + ), + }, { title: 'Time', dataIndex: 'timestamp', @@ -479,37 +1234,29 @@ export default function PacketViewer(): ReactElement { dataIndex: 'ip_proto', key: 'ip_proto', width: 130, - render: (_: any, rec: PacketTableRow) => {rec.__kind === 'flow' ? `${formatIpProto(rec)} flow` : formatProtocolLabel(rec)}, - }, - { - title: 'Flow', - key: 'flow_id', - width: 130, - render: (_: any, rec: PacketTableRow) => {rec.flow_id ?? '-'}, + render: (_: any, rec: PacketTableRow) => ( + + {rec.__kind === 'flow' ? `${formatIpProto(rec)} flow` : formatProtocolLabel(rec)} + {rec.__kind === 'packet' ? {buildProtocolBadges(rec)} : null} + + ), }, { title: 'App', key: 'app', - width: 170, + width: 250, render: (_: any, rec: PacketRow) => { const app = rec.app_protocol ?? rec.app_master_protocol ?? '-'; return ( {app} - - {rec.app_confidence ?? rec.app_category ?? ''} - + + {buildAppBadges(rec)} + ); }, }, - { - title: 'Host', - dataIndex: 'app_hostname', - key: 'app_hostname', - width: 180, - render: (v: any) => {v ?? '-'}, - }, { title: 'Src IP:Port', key: 'src', @@ -562,7 +1309,7 @@ export default function PacketViewer(): ReactElement { size="small" icon={} onClick={() => openRawModal(rec)} - aria-label="View raw and tshark data" + aria-label="Inspect packet" /> {rec.raw_b64 && ( @@ -591,7 +1338,35 @@ export default function PacketViewer(): ReactElement { ); return ( -
+
+ Packets @@ -644,26 +1419,39 @@ export default function PacketViewer(): ReactElement { 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 ? ( + } - width={900} + width={1100} > - -
- Raw bytes -
{rawHex}
-
-
- TShark data -
{rawTshark}
-
-
+ + + {formatTimestamp(selectedPacket?.timestamp)} + {selectedPacket?.flow_id ?? getFlowId(selectedPacket ?? {}) ?? '-'} + {selectedPacket ? formatProtocolLabel(selectedPacket) : '-'} + {selectedPacket?.app_protocol ?? selectedPacket?.app_master_protocol ?? '-'} + {selectedPacket?.app_hostname ?? '-'} + {selectedPacket?.verdict ?? '-'} + + {selectedPacket?.src_ip ? `${selectedPacket.src_ip}${selectedPacket?.src_port ? `:${selectedPacket.src_port}` : ''}` : '-'} + + + {selectedPacket?.dst_ip ? `${selectedPacket.dst_ip}${selectedPacket?.dst_port ? `:${selectedPacket.dst_port}` : ''}` : '-'} + + + {[selectedPacket?.ingress_if, selectedPacket?.egress_if].filter(Boolean).join(' -> ') || '-'} + + + {selectedPacket?.capture_sources?.join(', ') || '-'} + + +
+ Reduced metadata +
+                      {formatJson(reducedMetadata)}
+                    
+
+ {decodedPayload?.headersText ? ( +
+ Decoded headers +
+                        {decodedPayload.headersText}
+                      
+
+ ) : null} + {decodedPayload?.bodyText ? ( +
+ Decoded body +
+                        {decodedPayload.bodyText}
+                      
+
+ ) : decodedPayload?.payloadText ? ( +
+ Decoded payload +
+                        {decodedPayload.payloadText}
+                      
+
+ ) : null} + + ), + }, + { + key: 'raw', + label: 'Raw', + children: ( +
+                  {rawHex}
+                
+ ), + }, + { + key: 'full', + label: 'Full JSON', + children: ( +
+                  {fullPacketJson}
+                
+ ), + }, + ]} + />
);