tshark protocol restriction lifted, frontedn details for packet
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 11s

This commit is contained in:
2026-03-08 16:54:28 +01:00
parent c41e68c0f3
commit 5ea4dd388e
3 changed files with 481 additions and 286 deletions

View File

@@ -271,11 +271,12 @@ class DatabasePool:
self, self,
*, *,
iface: str, iface: str,
eth_type_raw: Optional[int],
src_ip: str, src_ip: str,
dst_ip: str, dst_ip: str,
src_port: int, src_port: int,
dst_port: int, dst_port: int,
protocol: int, protocol: Optional[int],
length: int, length: int,
observed_at_ms: int, observed_at_ms: int,
enrichment: Dict[str, Any], enrichment: Dict[str, Any],
@@ -297,35 +298,39 @@ class DatabasePool:
UPDATE packets UPDATE packets
SET SET
updated_at = NOW(), updated_at = NOW(),
app_protocol = COALESCE(packets.app_protocol, $10), app_protocol = COALESCE(packets.app_protocol, $11),
app_master_protocol = COALESCE(packets.app_master_protocol, $11), app_master_protocol = COALESCE(packets.app_master_protocol, $12),
app_category = COALESCE(packets.app_category, $12), app_category = COALESCE(packets.app_category, $13),
app_confidence = COALESCE(packets.app_confidence, $13), app_confidence = COALESCE(packets.app_confidence, $14),
app_hostname = COALESCE(packets.app_hostname, $14), app_hostname = COALESCE(packets.app_hostname, $15),
app_is_encrypted = COALESCE(packets.app_is_encrypted, $15), app_is_encrypted = COALESCE(packets.app_is_encrypted, $16),
dpi_metadata = CASE dpi_metadata = CASE
WHEN $16::jsonb IS NULL THEN packets.dpi_metadata WHEN $17::jsonb IS NULL THEN packets.dpi_metadata
WHEN packets.dpi_metadata IS NULL THEN $16::jsonb WHEN packets.dpi_metadata IS NULL THEN $17::jsonb
ELSE packets.dpi_metadata || $16::jsonb ELSE packets.dpi_metadata || $17::jsonb
END, END,
capture_sources = ( capture_sources = (
SELECT ARRAY( SELECT ARRAY(
SELECT DISTINCT source SELECT DISTINCT source
FROM unnest( FROM unnest(
COALESCE(packets.capture_sources, ARRAY[]::text[]) || COALESCE(packets.capture_sources, ARRAY[]::text[]) ||
COALESCE($17::text[], ARRAY[]::text[]) COALESCE($18::text[], ARRAY[]::text[])
) AS source ) AS source
) )
) )
WHERE WHERE
ip_proto_raw = $1 (
AND (capture_iface = $2 OR ingress_if = $2 OR egress_if = $2) ($1::int IS NOT NULL AND ip_proto_raw = $1)
AND src_ip = $3::inet OR
AND dst_ip = $4::inet ($1::int IS NULL AND $2::int IS NOT NULL AND eth_type_raw = $2)
AND src_port = $5 )
AND dst_port = $6 AND (capture_iface = $3 OR ingress_if = $3 OR egress_if = $3)
AND length = $7 AND src_ip = $4::inet
AND timestamp BETWEEN $8 AND $9 AND dst_ip = $5::inet
AND COALESCE(src_port, 0) = $6
AND COALESCE(dst_port, 0) = $7
AND length = $8
AND timestamp BETWEEN $9 AND $10
AND ( AND (
packets.app_protocol IS NULL packets.app_protocol IS NULL
OR packets.app_master_protocol IS NULL OR packets.app_master_protocol IS NULL
@@ -333,12 +338,13 @@ class DatabasePool:
OR packets.app_confidence IS NULL OR packets.app_confidence IS NULL
OR packets.app_hostname IS NULL OR packets.app_hostname IS NULL
OR packets.app_is_encrypted IS NULL OR packets.app_is_encrypted IS NULL
OR ($16::jsonb IS NOT NULL) OR ($17::jsonb IS NOT NULL)
OR (COALESCE(array_length($17::text[], 1), 0) > 0) OR (COALESCE(array_length($18::text[], 1), 0) > 0)
) )
RETURNING * RETURNING *
""", """,
protocol, protocol,
eth_type_raw,
iface, iface,
src_ip, src_ip,
dst_ip, dst_ip,

View File

@@ -3,73 +3,38 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
import os import os
import signal import signal
import subprocess import subprocess
import threading import threading
import time import time
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Optional, Tuple from typing import Any, Dict, Iterable, List, Optional, Tuple
import src.shared_objects as shared_objects import src.shared_objects as shared_objects
from scapy.all import IP, IPv6, TCP, UDP # type: ignore from scapy.all import ARP, IP, IPv6, TCP, UDP # type: ignore
from src.config import settings from src.config import settings
logger = logging.getLogger("tshark_manager") logger = logging.getLogger("tshark_manager")
_FIELDS: List[str] = [ _GENERIC_PROTOCOLS = {
"frame.time_epoch", "",
"frame.interface_name", "frame",
"frame.len", "eth",
"_ws.col.Protocol", "ethertype",
"_ws.col.Info", "ip",
"ip.src", "ipv6",
"ipv6.src", "tcp",
"ip.dst", "udp",
"ipv6.dst", "data",
"ip.proto", "data_text_lines",
"ipv6.nxt", "text",
"tcp.stream", "text_lines",
"udp.stream", "geninfo",
"tcp.srcport", }
"udp.srcport",
"tcp.dstport",
"udp.dstport",
"tcp.flags",
"tcp.seq_raw",
"tcp.ack_raw",
"tcp.len",
"tcp.analysis.retransmission",
"tcp.analysis.fast_retransmission",
"tcp.analysis.spurious_retransmission",
"tcp.analysis.keep_alive",
"tcp.analysis.keep_alive_ack",
"tcp.analysis.duplicate_ack",
"arp.opcode",
"icmp.type",
"icmp.code",
"icmpv6.type",
"icmpv6.code",
"frame.protocols",
"http.request.method",
"http.request.uri",
"http.host",
"http.user_agent",
"http.response.code",
"http.response.phrase",
"http.server",
"http.content_type",
"tls.handshake.extensions_server_name",
"tls.handshake.version",
"dns.flags.response",
"dns.qry.name",
"dns.qry.type",
"dns.resp.name",
"dns.a",
"dns.aaaa",
"dns.cname",
]
def _safe_text(value: Any) -> Optional[str]: def _safe_text(value: Any) -> Optional[str]:
@@ -84,7 +49,7 @@ def _safe_int(value: Any) -> Optional[int]:
text = _safe_text(value) text = _safe_text(value)
if text is None: if text is None:
return None return None
return int(text) return int(text, 0)
except Exception: except Exception:
return None return None
@@ -110,6 +75,267 @@ def _safe_bool_flag(value: Any) -> Optional[bool]:
return None return None
def _coerce_json_value(value: Any) -> Any:
if isinstance(value, dict):
out: Dict[str, Any] = {}
for key, child in value.items():
coerced = _coerce_json_value(child)
if coerced in (None, "", [], {}):
continue
out[str(key)] = coerced
return out
if isinstance(value, list):
out = [_coerce_json_value(child) for child in value]
out = [child for child in out if child not in (None, "", [], {})]
if not out:
return None
if len(out) == 1:
return out[0]
return out
return value
def _iter_nodes(value: Any) -> Iterable[Tuple[Optional[str], Any]]:
if isinstance(value, dict):
for key, child in value.items():
yield key, child
yield from _iter_nodes(child)
elif isinstance(value, list):
for child in value:
yield None, child
yield from _iter_nodes(child)
def _key_matches_suffix(key: str, suffixes: Tuple[str, ...]) -> bool:
normalized = key.lower().replace(".", "_")
return any(normalized == suffix or normalized.endswith(f"_{suffix}") for suffix in suffixes)
def _find_value(value: Any, *suffixes: str) -> Any:
normalized_suffixes = tuple(suffix.lower().replace(".", "_") for suffix in suffixes)
if isinstance(value, dict):
for key, child in _iter_nodes(value):
if key is None or not _key_matches_suffix(key, normalized_suffixes):
continue
coerced = _coerce_json_value(child)
if coerced not in (None, "", [], {}):
return coerced
return None
def _parse_time_ms(value: Any) -> Optional[int]:
float_value = _safe_float(value)
if float_value is not None:
if float_value > 1_000_000_000_000:
return int(float_value)
return int(float_value * 1000)
text = _safe_text(value)
if text is None:
return None
try:
return int(datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp() * 1000)
except Exception:
return None
def _jsonable(metadata: Dict[str, Any]) -> Dict[str, Any]:
out: Dict[str, Any] = {}
for key, value in metadata.items():
if value in (None, "", [], {}):
continue
out[key] = value
return out
def _packet_signature(
iface: str,
eth_type_raw: int,
protocol: Optional[int],
src_ip: str,
dst_ip: str,
src_port: int,
dst_port: int,
length: int,
) -> Tuple[str, int, int, str, str, int, int, int]:
return (
str(iface or ""),
int(eth_type_raw or 0),
int(protocol if protocol is not None else -1),
str(src_ip),
str(dst_ip),
int(src_port),
int(dst_port),
int(length),
)
def _signature_from_packet(pkt: Any, iface: str) -> Optional[Tuple[str, int, int, str, str, int, int, int]]:
eth_type_raw: Optional[int] = None
protocol: Optional[int] = None
src_ip: Optional[str] = None
dst_ip: Optional[str] = None
if IP in pkt:
ip_layer = pkt[IP]
eth_type_raw = 0x0800
protocol = int(getattr(ip_layer, "proto", 0) or 0)
src_ip = str(getattr(ip_layer, "src", "") or "")
dst_ip = str(getattr(ip_layer, "dst", "") or "")
elif IPv6 in pkt:
ip_layer = pkt[IPv6]
eth_type_raw = 0x86DD
protocol = int(getattr(ip_layer, "nh", 0) or 0)
src_ip = str(getattr(ip_layer, "src", "") or "")
dst_ip = str(getattr(ip_layer, "dst", "") or "")
elif ARP in pkt:
arp_layer = pkt[ARP]
eth_type_raw = 0x0806
src_ip = str(getattr(arp_layer, "psrc", "") or "")
dst_ip = str(getattr(arp_layer, "pdst", "") or "")
else:
return None
src_port = 0
dst_port = 0
if TCP in pkt:
src_port = int(getattr(pkt[TCP], "sport", 0) or 0)
dst_port = int(getattr(pkt[TCP], "dport", 0) or 0)
elif UDP in pkt:
src_port = int(getattr(pkt[UDP], "sport", 0) or 0)
dst_port = int(getattr(pkt[UDP], "dport", 0) or 0)
if not src_ip or not dst_ip or eth_type_raw is None:
return None
return _packet_signature(iface, eth_type_raw, protocol, src_ip, dst_ip, src_port, dst_port, len(pkt))
def _packet_observed_at_ms(pkt: Any) -> int:
try:
packet_time = float(getattr(pkt, "time", 0.0) or 0.0)
if packet_time > 0:
return int(packet_time * 1000)
except Exception:
pass
return int(time.time() * 1000)
def _stream_key(event: Dict[str, Any]) -> Optional[Tuple[str, str, int]]:
iface = str(event.get("iface") or "")
tcp_stream = event.get("tcp_stream")
if tcp_stream is not None:
return (iface, "tcp", int(tcp_stream))
udp_stream = event.get("udp_stream")
if udp_stream is not None:
return (iface, "udp", int(udp_stream))
return None
def _protocol_stack(layers: Dict[str, Any]) -> List[str]:
frame_layer = layers.get("frame")
raw_protocols = _find_value(frame_layer, "frame_protocols", "protocols")
text = _safe_text(raw_protocols)
if text:
return [part.strip() for part in text.split(":") if part.strip()]
return [str(key) for key in layers.keys() if key]
def _display_protocol_name(token: Optional[str]) -> Optional[str]:
text = _safe_text(token)
if text is None:
return None
normalized = text.replace("-", "_").replace(".", "_").lower()
mapping = {
"http": "HTTP",
"http2": "HTTP2",
"dns": "DNS",
"tls": "TLS",
"ssl": "TLS",
"quic": "QUIC",
"tcp": "TCP",
"udp": "UDP",
"arp": "ARP",
"icmp": "ICMP",
"icmpv6": "ICMPV6",
"ip": "IP",
"ipv6": "IPv6",
}
return mapping.get(normalized, text.upper())
def _meaningful_protocol(protocol_stack: List[str], layers: Dict[str, Any]) -> Optional[str]:
for token in reversed(protocol_stack):
normalized = token.replace("-", "_").replace(".", "_").lower()
if normalized not in _GENERIC_PROTOCOLS:
return _display_protocol_name(token)
for token in reversed(list(layers.keys())):
normalized = str(token).replace("-", "_").replace(".", "_").lower()
if normalized not in _GENERIC_PROTOCOLS:
return _display_protocol_name(str(token))
if protocol_stack:
return _display_protocol_name(protocol_stack[-1])
return None
def _eth_type_from_protocol_stack(protocol_stack: List[str]) -> Optional[int]:
normalized = {token.replace("-", "_").replace(".", "_").lower() for token in protocol_stack}
if "arp" in normalized:
return 0x0806
if "ipv6" in normalized:
return 0x86DD
if "ip" in normalized:
return 0x0800
return None
def _http_metadata(layers: Dict[str, Any]) -> Dict[str, Any]:
http_layer = layers.get("http")
if not isinstance(http_layer, dict):
return {}
return _jsonable(
{
"method": _safe_text(_find_value(http_layer, "request_method", "method")),
"uri": _safe_text(_find_value(http_layer, "request_uri", "uri", "full_uri")),
"host": _safe_text(_find_value(http_layer, "host")),
"user_agent": _safe_text(_find_value(http_layer, "user_agent")),
"response_code": _safe_int(_find_value(http_layer, "response_code", "code")),
"response_phrase": _safe_text(_find_value(http_layer, "response_phrase", "phrase")),
"server": _safe_text(_find_value(http_layer, "server")),
"content_type": _safe_text(_find_value(http_layer, "content_type")),
}
)
def _tls_metadata(layers: Dict[str, Any]) -> Dict[str, Any]:
tls_layer = layers.get("tls") or layers.get("ssl")
if not isinstance(tls_layer, dict):
return {}
return _jsonable(
{
"server_name": _safe_text(_find_value(tls_layer, "handshake_extensions_server_name", "server_name")),
"handshake_version": _safe_text(_find_value(tls_layer, "handshake_version", "record_version", "version")),
"alpn": _safe_text(_find_value(tls_layer, "handshake_extensions_alpn_str", "alpn_str")),
}
)
def _dns_metadata(layers: Dict[str, Any]) -> Dict[str, Any]:
dns_layer = layers.get("dns")
if not isinstance(dns_layer, dict):
return {}
return _jsonable(
{
"is_response": _safe_bool_flag(_find_value(dns_layer, "flags_response", "response")),
"query_name": _safe_text(_find_value(dns_layer, "qry_name", "qry_name_len")),
"query_type": _safe_text(_find_value(dns_layer, "qry_type")),
"response_name": _safe_text(_find_value(dns_layer, "resp_name")),
"a": _coerce_json_value(_find_value(dns_layer, "a")),
"aaaa": _coerce_json_value(_find_value(dns_layer, "aaaa")),
"cname": _coerce_json_value(_find_value(dns_layer, "cname")),
}
)
def _tcp_flags_summary(flags_value: Optional[int], payload_len: int) -> Optional[str]: def _tcp_flags_summary(flags_value: Optional[int], payload_len: int) -> Optional[str]:
if flags_value is None: if flags_value is None:
return None return None
@@ -164,161 +390,95 @@ def _tcp_flag_names(flags_value: Optional[int]) -> List[str]:
return names return names
def _jsonable(metadata: Dict[str, Any]) -> Dict[str, Any]: def _extract_event(record: Dict[str, Any], fallback_iface: str) -> Optional[Dict[str, Any]]:
out: Dict[str, Any] = {} layers_raw = record.get("layers")
for key, value in metadata.items(): if not isinstance(layers_raw, dict):
if value in (None, "", [], {}):
continue
out[key] = value
return out
def _stream_key(event: Dict[str, Any]) -> Optional[Tuple[str, str, int]]:
iface = str(event.get("iface") or "")
tcp_stream = event.get("tcp_stream")
if tcp_stream is not None:
return (iface, "tcp", int(tcp_stream))
udp_stream = event.get("udp_stream")
if udp_stream is not None:
return (iface, "udp", int(udp_stream))
return None return None
layers = _coerce_json_value(layers_raw)
def _packet_signature( if not isinstance(layers, dict):
iface: str,
protocol: int,
src_ip: str,
dst_ip: str,
src_port: int,
dst_port: int,
length: int,
) -> Tuple[str, int, str, str, int, int, int]:
return (str(iface or ""), int(protocol), str(src_ip), str(dst_ip), int(src_port), int(dst_port), int(length))
def _signature_from_packet(pkt: Any, iface: str) -> Optional[Tuple[str, int, str, str, int, int, int]]:
if IP in pkt:
ip_layer = pkt[IP]
protocol = int(getattr(ip_layer, "proto", 0) or 0)
src_ip = str(getattr(ip_layer, "src", "") or "")
dst_ip = str(getattr(ip_layer, "dst", "") or "")
elif IPv6 in pkt:
ip_layer = pkt[IPv6]
protocol = int(getattr(ip_layer, "nh", 0) or 0)
src_ip = str(getattr(ip_layer, "src", "") or "")
dst_ip = str(getattr(ip_layer, "dst", "") or "")
else:
return None return None
src_port = 0 protocol_stack = _protocol_stack(layers)
dst_port = 0 frame_layer = layers.get("frame")
if TCP in pkt: ip_layer = layers.get("ip")
src_port = int(getattr(pkt[TCP], "sport", 0) or 0) ipv6_layer = layers.get("ipv6")
dst_port = int(getattr(pkt[TCP], "dport", 0) or 0) tcp_layer = layers.get("tcp")
elif UDP in pkt: udp_layer = layers.get("udp")
src_port = int(getattr(pkt[UDP], "sport", 0) or 0)
dst_port = int(getattr(pkt[UDP], "dport", 0) or 0)
if not src_ip or not dst_ip: observed_at_ms = (
return None _parse_time_ms(record.get("timestamp"))
return _packet_signature(iface, protocol, src_ip, dst_ip, src_port, dst_port, len(pkt)) or _parse_time_ms(_find_value(frame_layer, "frame_time_epoch", "time_epoch", "time"))
or int(time.time() * 1000)
)
iface = _safe_text(_find_value(frame_layer, "frame_interface_name", "interface_name")) or fallback_iface
length = _safe_int(_find_value(frame_layer, "frame_len", "len"))
eth_type_raw = _eth_type_from_protocol_stack(protocol_stack)
protocol = _safe_int(_find_value(ip_layer, "ip_proto", "proto"))
if protocol is None:
protocol = _safe_int(_find_value(ipv6_layer, "ipv6_nxt", "nxt"))
src_ip = (
_safe_text(_find_value(ip_layer, "ip_src", "src"))
or _safe_text(_find_value(ipv6_layer, "ipv6_src", "src"))
or _safe_text(_find_value(layers.get("arp"), "src_proto_ipv4", "src_proto_ipv6"))
)
dst_ip = (
_safe_text(_find_value(ip_layer, "ip_dst", "dst"))
or _safe_text(_find_value(ipv6_layer, "ipv6_dst", "dst"))
or _safe_text(_find_value(layers.get("arp"), "dst_proto_ipv4", "dst_proto_ipv6"))
)
src_port = _safe_int(_find_value(tcp_layer, "srcport")) or _safe_int(_find_value(udp_layer, "srcport")) or 0
dst_port = _safe_int(_find_value(tcp_layer, "dstport")) or _safe_int(_find_value(udp_layer, "dstport")) or 0
tcp_flags = _safe_int(_find_value(tcp_layer, "flags"))
tcp_len = _safe_int(_find_value(tcp_layer, "len")) or 0
display_protocol = (
_safe_text(_find_value(frame_layer, "col_protocol", "protocol"))
or _meaningful_protocol(protocol_stack, layers)
)
info_col = _safe_text(_find_value(frame_layer, "col_info", "info"))
def _packet_observed_at_ms(pkt: Any) -> int: if length is None or src_ip is None or dst_ip is None:
try:
packet_time = float(getattr(pkt, "time", 0.0) or 0.0)
if packet_time > 0:
return int(packet_time * 1000)
except Exception:
pass
return int(time.time() * 1000)
def _parse_line(line: str, fallback_iface: str) -> Optional[Dict[str, Any]]:
parts = line.rstrip("\n").split("\t")
if len(parts) < len(_FIELDS):
parts.extend([""] * (len(_FIELDS) - len(parts)))
row = dict(zip(_FIELDS, parts))
timestamp = _safe_float(row["frame.time_epoch"])
length = _safe_int(row["frame.len"])
protocol_col = _safe_text(row["_ws.col.Protocol"])
info_col = _safe_text(row["_ws.col.Info"])
protocol = _safe_int(row["ip.proto"]) or _safe_int(row["ipv6.nxt"])
tcp_stream = _safe_int(row["tcp.stream"])
udp_stream = _safe_int(row["udp.stream"])
src_ip = _safe_text(row["ip.src"]) or _safe_text(row["ipv6.src"])
dst_ip = _safe_text(row["ip.dst"]) or _safe_text(row["ipv6.dst"])
src_port = _safe_int(row["tcp.srcport"]) or _safe_int(row["udp.srcport"]) or 0
dst_port = _safe_int(row["tcp.dstport"]) or _safe_int(row["udp.dstport"]) or 0
iface = _safe_text(row["frame.interface_name"]) or fallback_iface
if timestamp is None or length is None or protocol is None or src_ip is None or dst_ip is None:
return None return None
observed_at_ms = int(timestamp * 1000)
return { return {
"iface": iface, "iface": iface,
"observed_at_ms": observed_at_ms, "observed_at_ms": observed_at_ms,
"length": length, "length": length,
"eth_type_raw": eth_type_raw,
"protocol": protocol, "protocol": protocol,
"src_ip": src_ip, "src_ip": src_ip,
"dst_ip": dst_ip, "dst_ip": dst_ip,
"src_port": src_port, "src_port": src_port,
"dst_port": dst_port, "dst_port": dst_port,
"tcp_stream": tcp_stream, "tcp_stream": _safe_int(_find_value(tcp_layer, "stream")),
"udp_stream": udp_stream, "udp_stream": _safe_int(_find_value(udp_layer, "stream")),
"tcp_flags": _safe_int(row["tcp.flags"]), "tcp_flags": tcp_flags,
"tcp_seq_raw": _safe_int(row["tcp.seq_raw"]), "tcp_seq_raw": _safe_int(_find_value(tcp_layer, "seq_raw")),
"tcp_ack_raw": _safe_int(row["tcp.ack_raw"]), "tcp_ack_raw": _safe_int(_find_value(tcp_layer, "ack_raw")),
"tcp_len": _safe_int(row["tcp.len"]) or 0, "tcp_len": tcp_len,
"tcp_retransmission": _safe_bool_flag(row["tcp.analysis.retransmission"]), "tcp_retransmission": _safe_bool_flag(_find_value(tcp_layer, "analysis_retransmission")),
"tcp_fast_retransmission": _safe_bool_flag(row["tcp.analysis.fast_retransmission"]), "tcp_fast_retransmission": _safe_bool_flag(_find_value(tcp_layer, "analysis_fast_retransmission")),
"tcp_spurious_retransmission": _safe_bool_flag(row["tcp.analysis.spurious_retransmission"]), "tcp_spurious_retransmission": _safe_bool_flag(_find_value(tcp_layer, "analysis_spurious_retransmission")),
"tcp_keep_alive": _safe_bool_flag(row["tcp.analysis.keep_alive"]), "tcp_keep_alive": _safe_bool_flag(_find_value(tcp_layer, "analysis_keep_alive")),
"tcp_keep_alive_ack": _safe_bool_flag(row["tcp.analysis.keep_alive_ack"]), "tcp_keep_alive_ack": _safe_bool_flag(_find_value(tcp_layer, "analysis_keep_alive_ack")),
"tcp_duplicate_ack": _safe_bool_flag(row["tcp.analysis.duplicate_ack"]), "tcp_duplicate_ack": _safe_bool_flag(_find_value(tcp_layer, "analysis_duplicate_ack")),
"arp_opcode": _safe_int(row["arp.opcode"]), "arp_opcode": _safe_int(_find_value(layers.get("arp"), "opcode")),
"icmp_type": _safe_int(row["icmp.type"]) or _safe_int(row["icmpv6.type"]), "icmp_type": _safe_int(_find_value(layers.get("icmp"), "type")) or _safe_int(_find_value(layers.get("icmpv6"), "type")),
"icmp_code": _safe_int(row["icmp.code"]) or _safe_int(row["icmpv6.code"]), "icmp_code": _safe_int(_find_value(layers.get("icmp"), "code")) or _safe_int(_find_value(layers.get("icmpv6"), "code")),
"protocol_col": protocol_col, "protocol_col": display_protocol,
"info_col": info_col, "info_col": info_col,
"frame_protocols": _safe_text(row["frame.protocols"]), "frame_protocols": ":".join(protocol_stack) if protocol_stack else None,
"http": _jsonable( "protocol_stack": protocol_stack,
{ "http": _http_metadata(layers),
"method": _safe_text(row["http.request.method"]), "tls": _tls_metadata(layers),
"uri": _safe_text(row["http.request.uri"]), "dns": _dns_metadata(layers),
"host": _safe_text(row["http.host"]), "layers": layers,
"user_agent": _safe_text(row["http.user_agent"]),
"response_code": _safe_int(row["http.response.code"]),
"response_phrase": _safe_text(row["http.response.phrase"]),
"server": _safe_text(row["http.server"]),
"content_type": _safe_text(row["http.content_type"]),
}
),
"tls": _jsonable(
{
"server_name": _safe_text(row["tls.handshake.extensions_server_name"]),
"handshake_version": _safe_text(row["tls.handshake.version"]),
}
),
"dns": _jsonable(
{
"is_response": _safe_bool_flag(row["dns.flags.response"]),
"query_name": _safe_text(row["dns.qry.name"]),
"query_type": _safe_text(row["dns.qry.type"]),
"response_name": _safe_text(row["dns.resp.name"]),
"a": _safe_text(row["dns.a"]),
"aaaa": _safe_text(row["dns.aaaa"]),
"cname": _safe_text(row["dns.cname"]),
}
),
} }
def _derive_flow_context(event: Dict[str, Any]) -> Dict[str, Any]: def _derive_flow_context(event: Dict[str, Any]) -> Dict[str, Any]:
protocol_col = _safe_text(event.get("protocol_col")) protocol_stack = list(event.get("protocol_stack") or [])
protocols = str(event.get("frame_protocols") or "")
http_meta = dict(event.get("http") or {}) http_meta = dict(event.get("http") or {})
tls_meta = dict(event.get("tls") or {}) tls_meta = dict(event.get("tls") or {})
dns_meta = dict(event.get("dns") or {}) dns_meta = dict(event.get("dns") or {})
@@ -338,19 +498,20 @@ def _derive_flow_context(event: Dict[str, Any]) -> Dict[str, Any]:
app_category = "Infrastructure" app_category = "Infrastructure"
app_hostname = dns_meta.get("query_name") or dns_meta.get("response_name") app_hostname = dns_meta.get("query_name") or dns_meta.get("response_name")
app_is_encrypted = False app_is_encrypted = False
elif tls_meta or "quic" in protocols.lower(): elif tls_meta:
app_protocol = "QUIC" if "quic" in protocols.lower() and not tls_meta else "TLS" app_protocol = "TLS"
app_category = "Encrypted" app_category = "Encrypted"
app_hostname = tls_meta.get("server_name") app_hostname = tls_meta.get("server_name")
app_is_encrypted = True app_is_encrypted = True
elif protocol_col: else:
app_protocol = protocol_col app_protocol = _safe_text(event.get("protocol_col"))
if app_protocol == "TCP": normalized = str(app_protocol or "").upper()
if normalized == "TCP":
app_category = app_category or "Transport" app_category = app_category or "Transport"
elif app_protocol == "UDP": elif normalized == "UDP":
app_category = app_category or "Transport" app_category = app_category or "Transport"
elif app_protocol in {"ARP", "ICMP", "ICMPV6", "IP", "IPV6"}: elif normalized in {"ARP", "ICMP", "ICMPV6", "IP", "IPV6"}:
app_category = app_category or "Network" app_category = app_category or "Network"
elif app_protocol and app_category is None: elif app_protocol and app_category is None:
app_category = "Protocol" app_category = "Protocol"
@@ -367,14 +528,9 @@ def _derive_flow_context(event: Dict[str, Any]) -> Dict[str, Any]:
def _build_enrichment(event: Dict[str, Any], flow_context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: def _build_enrichment(event: Dict[str, Any], flow_context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
http_meta = dict(event.get("http") or {})
tls_meta = dict(event.get("tls") or {})
dns_meta = dict(event.get("dns") or {})
protocols = str(event.get("frame_protocols") or "")
protocol_col = _safe_text(event.get("protocol_col"))
info_col = _safe_text(event.get("info_col"))
flow_context = dict(flow_context or {}) flow_context = dict(flow_context or {})
derived_context = _derive_flow_context(event) derived_context = _derive_flow_context(event)
protocol_stack = list(event.get("protocol_stack") or [])
app_protocol = derived_context.get("app_protocol") or flow_context.get("app_protocol") app_protocol = derived_context.get("app_protocol") or flow_context.get("app_protocol")
app_category = derived_context.get("app_category") or flow_context.get("app_category") app_category = derived_context.get("app_category") or flow_context.get("app_category")
@@ -394,10 +550,12 @@ def _build_enrichment(event: Dict[str, Any], flow_context: Optional[Dict[str, An
tshark_meta = _jsonable( tshark_meta = _jsonable(
{ {
"observed_at_ms": event.get("observed_at_ms"), "observed_at_ms": event.get("observed_at_ms"),
"protocol": protocol_col, "protocol": event.get("protocol_col"),
"info": info_col, "info": event.get("info_col"),
"frame_protocols": event.get("frame_protocols"), "frame_protocols": event.get("frame_protocols"),
"protocol_stack": protocol_stack,
"length": event.get("length"), "length": event.get("length"),
"eth_type_raw": event.get("eth_type_raw"),
"tcp_stream": event.get("tcp_stream"), "tcp_stream": event.get("tcp_stream"),
"udp_stream": event.get("udp_stream"), "udp_stream": event.get("udp_stream"),
"tcp_packet_type": tcp_packet_type, "tcp_packet_type": tcp_packet_type,
@@ -408,6 +566,7 @@ def _build_enrichment(event: Dict[str, Any], flow_context: Optional[Dict[str, An
dpi_metadata = _jsonable( dpi_metadata = _jsonable(
{ {
"tshark": tshark_meta, "tshark": tshark_meta,
"layers": event.get("layers"),
"tcp": _jsonable( "tcp": _jsonable(
{ {
"stream": event.get("tcp_stream"), "stream": event.get("tcp_stream"),
@@ -428,9 +587,9 @@ def _build_enrichment(event: Dict[str, Any], flow_context: Optional[Dict[str, An
"udp": _jsonable({"stream": event.get("udp_stream")}), "udp": _jsonable({"stream": event.get("udp_stream")}),
"icmp": _jsonable({"type": event.get("icmp_type"), "code": event.get("icmp_code")}), "icmp": _jsonable({"type": event.get("icmp_type"), "code": event.get("icmp_code")}),
"arp": _jsonable({"opcode": event.get("arp_opcode")}), "arp": _jsonable({"opcode": event.get("arp_opcode")}),
"http": http_meta, "http": dict(event.get("http") or {}),
"tls": tls_meta, "tls": dict(event.get("tls") or {}),
"dns": dns_meta, "dns": dict(event.get("dns") or {}),
} }
) )
@@ -473,7 +632,7 @@ def _build_stream_enrichment(flow_context: Dict[str, Any]) -> Dict[str, Any]:
def _is_generic_stream_protocol(protocol_name: Optional[str]) -> bool: def _is_generic_stream_protocol(protocol_name: Optional[str]) -> bool:
return str(protocol_name or "").upper() in {"", "TCP", "UDP", "IP", "IPV6", "ETH"} return str(protocol_name or "").replace("-", "_").replace(".", "_").lower() in _GENERIC_PROTOCOLS
class TsharkManager: class TsharkManager:
@@ -482,7 +641,7 @@ class TsharkManager:
def __init__(self) -> None: def __init__(self) -> None:
self._enabled = settings.tshark_enabled self._enabled = settings.tshark_enabled
self._workers: Dict[str, Dict[str, Any]] = {} self._workers: Dict[str, Dict[str, Any]] = {}
self._cache: Dict[Tuple[str, int, str, str, int, int, int], List[Dict[str, Any]]] = {} self._cache: Dict[Tuple[str, int, int, str, str, int, int, int], List[Dict[str, Any]]] = {}
self._flow_context: Dict[Tuple[str, str, int], Dict[str, Any]] = {} self._flow_context: Dict[Tuple[str, str, int], Dict[str, Any]] = {}
self._last_error_by_iface: Dict[str, str] = {} self._last_error_by_iface: Dict[str, str] = {}
self._stats: Dict[str, Any] = { self._stats: Dict[str, Any] = {
@@ -587,23 +746,13 @@ class TsharkManager:
"-i", "-i",
iface, iface,
"-T", "-T",
"fields", "ek",
"-E",
"header=n",
"-E",
"separator=\t",
"-E",
"quote=n",
"-E",
"occurrence=f",
] ]
if settings.tshark_try_heuristic_first: if settings.tshark_try_heuristic_first:
cmd.extend(["-o", "tcp.try_heuristic_first:true"]) cmd.extend(["-o", "tcp.try_heuristic_first:true"])
cmd.extend(["-o", "udp.try_heuristic_first:true"]) cmd.extend(["-o", "udp.try_heuristic_first:true"])
if settings.tshark_display_filter: if settings.tshark_display_filter:
cmd.extend(["-Y", settings.tshark_display_filter]) cmd.extend(["-Y", settings.tshark_display_filter])
for field in _FIELDS:
cmd.extend(["-e", field])
logger.info("Starting tshark worker for %s", iface) logger.info("Starting tshark worker for %s", iface)
try: try:
@@ -675,17 +824,31 @@ class TsharkManager:
with self._lock: with self._lock:
self._stats["raw_lines_total"] += 1 self._stats["raw_lines_total"] += 1
event = _parse_line(text, iface) try:
if event is None: payload = json.loads(text)
except json.JSONDecodeError:
with self._lock: with self._lock:
self._stats["parse_failures"] += 1 self._stats["parse_failures"] += 1
self._stats["last_unparsed_line_by_iface"][iface] = text self._stats["last_unparsed_line_by_iface"][iface] = text
logger.debug("Ignoring unparsable tshark line on %s: %s", iface, text) logger.debug("Ignoring unparsable tshark line on %s: %s", iface, text)
continue continue
if not isinstance(payload, dict):
continue
if "index" in payload and "layers" not in payload:
continue
event = _extract_event(payload, iface)
if event is None:
with self._lock:
self._stats["parse_failures"] += 1
self._stats["last_unparsed_line_by_iface"][iface] = text
continue
signature = _packet_signature( signature = _packet_signature(
str(event["iface"]), str(event["iface"]),
int(event["protocol"]), int(event.get("eth_type_raw") or 0),
_safe_int(event.get("protocol")),
str(event["src_ip"]), str(event["src_ip"]),
str(event["dst_ip"]), str(event["dst_ip"]),
int(event["src_port"]), int(event["src_port"]),
@@ -702,7 +865,7 @@ class TsharkManager:
derived_context = _derive_flow_context(event) derived_context = _derive_flow_context(event)
if stream_key is not None and derived_context.get("app_protocol") is not None: if stream_key is not None and derived_context.get("app_protocol") is not None:
merged_context = dict(flow_context or {}) merged_context = dict(flow_context or {})
merged_context.update({k: v for k, v in derived_context.items() if v is not None}) merged_context.update({key: value for key, value in derived_context.items() if value is not None})
self._flow_context[stream_key] = merged_context self._flow_context[stream_key] = merged_context
flow_context = merged_context flow_context = merged_context
entries = self._cache.setdefault(signature, []) entries = self._cache.setdefault(signature, [])
@@ -710,20 +873,18 @@ class TsharkManager:
self._stats["events_total"] += 1 self._stats["events_total"] += 1
self._stats["last_event_by_iface"][iface] = { self._stats["last_event_by_iface"][iface] = {
"observed_at_ms": event.get("observed_at_ms"), "observed_at_ms": event.get("observed_at_ms"),
"eth_type_raw": event.get("eth_type_raw"),
"tcp_stream": event.get("tcp_stream"), "tcp_stream": event.get("tcp_stream"),
"udp_stream": event.get("udp_stream"), "udp_stream": event.get("udp_stream"),
"protocol_col": event.get("protocol_col"), "protocol_col": event.get("protocol_col"),
"info_col": event.get("info_col"), "info_col": event.get("info_col"),
"tcp_packet_type": _tcp_flags_summary(_safe_int(event.get("tcp_flags")), int(event.get("tcp_len") or 0)),
"protocol": event.get("protocol"), "protocol": event.get("protocol"),
"src_ip": event.get("src_ip"), "src_ip": event.get("src_ip"),
"dst_ip": event.get("dst_ip"), "dst_ip": event.get("dst_ip"),
"src_port": event.get("src_port"), "src_port": event.get("src_port"),
"dst_port": event.get("dst_port"), "dst_port": event.get("dst_port"),
"frame_protocols": event.get("frame_protocols"), "frame_protocols": event.get("frame_protocols"),
"http": event.get("http"), "protocol_stack": event.get("protocol_stack"),
"tls": event.get("tls"),
"dns": event.get("dns"),
} }
self._purge_cache_locked(now_ms=int(event["observed_at_ms"])) self._purge_cache_locked(now_ms=int(event["observed_at_ms"]))
@@ -752,11 +913,12 @@ class TsharkManager:
async def _backfill() -> None: async def _backfill() -> None:
updated = await web_db.backfill_packet_metadata( updated = await web_db.backfill_packet_metadata(
iface=str(event["iface"]), iface=str(event["iface"]),
eth_type_raw=_safe_int(event.get("eth_type_raw")),
src_ip=str(event["src_ip"]), src_ip=str(event["src_ip"]),
dst_ip=str(event["dst_ip"]), dst_ip=str(event["dst_ip"]),
src_port=int(event["src_port"]), src_port=int(event["src_port"]),
dst_port=int(event["dst_port"]), dst_port=int(event["dst_port"]),
protocol=int(event["protocol"]), protocol=_safe_int(event.get("protocol")),
length=int(event["length"]), length=int(event["length"]),
observed_at_ms=int(event["observed_at_ms"]), observed_at_ms=int(event["observed_at_ms"]),
enrichment=enrichment, enrichment=enrichment,
@@ -765,15 +927,6 @@ class TsharkManager:
if updated: if updated:
with self._lock: with self._lock:
self._stats["backfill_updates_total"] += updated self._stats["backfill_updates_total"] += updated
logger.debug(
"Backfilled tshark metadata for %s packets on %s %s:%s -> %s:%s",
updated,
event["iface"],
event["src_ip"],
event["src_port"],
event["dst_ip"],
event["dst_port"],
)
stream_key = _stream_key(event) stream_key = _stream_key(event)
if stream_key is None or not flow_context: if stream_key is None or not flow_context:
@@ -799,7 +952,7 @@ class TsharkManager:
stream_id = int(stream_key[2]) stream_id = int(stream_key[2])
stream_updated = await web_db.backfill_stream_metadata( stream_updated = await web_db.backfill_stream_metadata(
iface=str(event["iface"]), iface=str(event["iface"]),
protocol=int(event["protocol"]), protocol=_safe_int(event.get("protocol")) or 0,
stream_kind=stream_kind, stream_kind=stream_kind,
stream_id=stream_id, stream_id=stream_id,
observed_at_ms=int(event["observed_at_ms"]), observed_at_ms=int(event["observed_at_ms"]),
@@ -833,7 +986,7 @@ class TsharkManager:
now_ms = int(time.time() * 1000) now_ms = int(time.time() * 1000)
expiry_ms = int(settings.tshark_cache_ttl_seconds * 1000) expiry_ms = int(settings.tshark_cache_ttl_seconds * 1000)
stale_keys: List[Tuple[str, int, str, str, int, int, int]] = [] stale_keys: List[Tuple[str, int, int, str, str, int, int, int]] = []
for key, entries in self._cache.items(): for key, entries in self._cache.items():
fresh_entries = [ fresh_entries = [
event event

View File

@@ -1,5 +1,5 @@
// src/components/PacketViewer.tsx // src/components/PacketViewer.tsx
import { DeleteOutlined, DownloadOutlined, SyncOutlined } from '@ant-design/icons'; import { DeleteOutlined, DownloadOutlined, EyeOutlined, SyncOutlined } from '@ant-design/icons';
import { import {
Button, Button,
Card, Card,
@@ -47,6 +47,15 @@ function base64ToBlob(b64: string) {
return new Blob([arr.buffer], { type: 'application/octet-stream' }); return new Blob([arr.buffer], { type: 'application/octet-stream' });
} }
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)';
}
}
/** /**
* Improved deterministic color generation * Improved deterministic color generation
* *
@@ -133,6 +142,7 @@ export default function PacketViewer(): ReactElement {
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const [hexModalOpen, setHexModalOpen] = useState(false); const [hexModalOpen, setHexModalOpen] = useState(false);
const [rawHex, setRawHex] = useState<string | null>(null); const [rawHex, setRawHex] = useState<string | null>(null);
const [rawTshark, setRawTshark] = useState<string | null>(null);
const [rawB64ForDownload, setRawB64ForDownload] = useState<string | null>(null); const [rawB64ForDownload, setRawB64ForDownload] = useState<string | null>(null);
const [selectedPacket, setSelectedPacket] = useState<PacketRow | null>(null); const [selectedPacket, setSelectedPacket] = useState<PacketRow | null>(null);
const queuedDuringPause = useRef<PacketRow[]>([]); const queuedDuringPause = useRef<PacketRow[]>([]);
@@ -277,6 +287,7 @@ export default function PacketViewer(): ReactElement {
return; return;
} }
setRawHex(base64ToHex(row.raw_b64)); setRawHex(base64ToHex(row.raw_b64));
setRawTshark(formatJson(row.dpi_metadata ?? null));
setRawB64ForDownload(row.raw_b64 ?? null); setRawB64ForDownload(row.raw_b64 ?? null);
setSelectedPacket(row); setSelectedPacket(row);
setHexModalOpen(true); setHexModalOpen(true);
@@ -457,13 +468,19 @@ export default function PacketViewer(): ReactElement {
{ {
title: 'Actions', title: 'Actions',
key: 'actions', key: 'actions',
width: 180, width: 96,
render: (_: any, rec: PacketRow) => ( render: (_: any, rec: PacketRow) => (
<Space> <Space>
<Button size="small" onClick={() => openRawModal(rec)}> <Tooltip title="View raw + tshark">
View raw <Button
</Button> size="small"
icon={<EyeOutlined />}
onClick={() => openRawModal(rec)}
aria-label="View raw and tshark data"
/>
</Tooltip>
{rec.raw_b64 && ( {rec.raw_b64 && (
<Tooltip title="Download raw">
<Button <Button
size="small" size="small"
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
@@ -476,9 +493,9 @@ export default function PacketViewer(): ReactElement {
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}} }}
> aria-label="Download raw packet"
Download />
</Button> </Tooltip>
)} )}
</Space> </Space>
), ),
@@ -545,12 +562,22 @@ export default function PacketViewer(): ReactElement {
</Card> </Card>
<Modal <Modal
title={`Raw packet ${selectedPacket?.id ?? ''}`} title={`Raw + tshark ${selectedPacket?.id ?? ''}`}
open={hexModalOpen} open={hexModalOpen}
onCancel={() => setHexModalOpen(false)} onCancel={() => {
setHexModalOpen(false);
setRawTshark(null);
}}
footer={ footer={
<Space> <Space>
<Button onClick={() => setHexModalOpen(false)}>Close</Button> <Button
onClick={() => {
setHexModalOpen(false);
setRawTshark(null);
}}
>
Close
</Button>
<Button icon={<DownloadOutlined />} onClick={downloadRaw} type="primary"> <Button icon={<DownloadOutlined />} onClick={downloadRaw} type="primary">
Download raw Download raw
</Button> </Button>
@@ -558,7 +585,16 @@ export default function PacketViewer(): ReactElement {
} }
width={900} width={900}
> >
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12 }}>{rawHex}</pre> <Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div>
<Text strong>Raw bytes</Text>
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>{rawHex}</pre>
</div>
<div>
<Text strong>TShark data</Text>
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, marginTop: 8 }}>{rawTshark}</pre>
</div>
</Space>
</Modal> </Modal>
</div> </div>
); );