# src/sniffer.py import asyncio import logging import threading import time import socket import selectors import errno import struct from typing import Dict, List, Optional, Any, TypedDict, Union from uuid import uuid4 # NOTE: ensure this path points to your shared runtime module from src import shared_objects from scapy.all import ( Ether, ARP, IP, IPv6, TCP, UDP, ICMP, ICMPv6Unknown, Dot1Q, Raw, ) from src.utilities.interface_bridge_helpers import ( check_interface_exists, check_interface_up, get_bridge_ports_once, ) from src.config import settings from src.utilities.bridge_telemetry import bridge_telemetry_manager from src.utilities.packet_identity import build_packet_uid from src.utilities.packet_tracker import packet_tracker from src.utilities.tshark_manager import tshark_manager from src.Models.etherType import EtherTypeEnum, ethertype_from_int from src.Models.ip_protocol import IPProtocolEnum, protocol_from_number # ---- Logging ---------------------------------------------------------- logger = logging.getLogger("af_packet_sniffer") # ---- Session model ------------------------------------------- # sessions: session_id -> session dict sessions: Dict[str, Dict[str, Any]] = {} # session schema: # { # "thread": threading.Thread, # "stop_event": threading.Event, # "sockets": Dict[iface, socket.socket], # "label": str, # "is_bridge": bool, # "ports": List[str], # } # ------------------------- # PacketInfo typing # ------------------------- class PacketInfo(TypedDict, total=False): """TypedDict for parsed packet data used by persistence and telemetry.""" correlation_key: str correlation_source: str packet_id: Optional[str] packet_uid: str skb_mark: Optional[int] capture_source: str iface: str capture_iface: Optional[str] length: int raw: bytes src_mac: Optional[str] dst_mac: Optional[str] eth_type_raw: Optional[int] eth_type: Optional[Union[str, EtherTypeEnum]] vlan_id: Optional[int] protocol_raw: Optional[int] protocol: Optional[Union[str, IPProtocolEnum, int]] protocol_name: Optional[str] src_ip: Optional[str] dst_ip: Optional[str] src_port: Optional[int] dst_port: Optional[int] app_protocol: Optional[str] app_master_protocol: Optional[str] app_category: Optional[str] app_confidence: Optional[str] app_hostname: Optional[str] app_is_encrypted: Optional[bool] app_risk_score: Optional[int] dpi_metadata: Optional[Dict[str, Any]] capture_metadata: Optional[Dict[str, Any]] ip_id: Optional[int] icmp_type: Optional[int] icmp_code: Optional[int] arp_op: Optional[int] tcp_seq: Optional[int] tcp_ack: Optional[int] tcp_flags: Optional[int] icmp_embedded_src_ip: Optional[str] icmp_embedded_dst_ip: Optional[str] icmp_embedded_protocol: Optional[int] icmp_embedded_src_port: Optional[int] icmp_embedded_dst_port: Optional[int] # small bounded buffer for packets produced before shared_objects is ready _PACKET_BUFFER: List[PacketInfo] = [] _BUFFER_CAPACITY = settings.sniffer_buffer_capacity # Background asyncio loop used for internal tasks in this module (kept but not used for DB pool) async_loop = asyncio.new_event_loop() def _start_async_loop(loop: asyncio.AbstractEventLoop) -> None: asyncio.set_event_loop(loop) loop.run_forever() # Start background loop in daemon thread immediately threading.Thread(target=_start_async_loop, args=(async_loop,), daemon=True).start() # ------------------------- # Helpers for buffer draining # ------------------------- def drain_buffer_to_shared_db() -> None: """Replay buffered packets once the shared DB loop becomes available.""" try: web_loop = getattr(shared_objects, "web_loop", None) web_db = getattr(shared_objects, "db", None) if web_db is None or web_loop is None: return def _drain() -> None: while _PACKET_BUFFER: pkt = _PACKET_BUFFER.pop(0) try: packet_tracker.observe_packet(pkt) except Exception: _PACKET_BUFFER.insert(0, pkt) break try: web_loop.call_soon_threadsafe(_drain) except Exception: # fallback: run directly (best-effort) _drain() except Exception: logger.exception("Failed to drain packet buffer") # ------------------------- # Packet parsing # ------------------------- def _safe_get_attr(layer, attr: str): try: return getattr(layer, attr, None) except Exception: return None def _merge_enrichment(pkt_info: PacketInfo, enrichment: Dict[str, Any]) -> None: """Populate enrichment fields without discarding existing metadata.""" for key, value in enrichment.items(): if value is None: continue if key == "dpi_metadata": current = pkt_info.get("dpi_metadata") if isinstance(current, dict) and isinstance(value, dict): pkt_info["dpi_metadata"] = {**current, **value} elif current is None: pkt_info["dpi_metadata"] = value continue if pkt_info.get(key) is None: pkt_info[key] = value def parse_packet(pkt, bridge_label: str, capture_metadata: Optional[Dict[str, Any]] = None) -> None: """ Parse a scapy Packet object into a normalized PacketInfo and schedule DB insert. bridge_label indicates whether the packet was captured as part of a bridge-snapshot or single-interface. """ pkt_iface = getattr(pkt, "sniffed_on", None) if not pkt_iface: return # can't determine interface context logger.debug("Packet captured on %s (bridge_label %s)", pkt_iface, bridge_label) pkt_info: PacketInfo = { "iface": pkt_iface, "capture_iface": None, "length": len(pkt), "raw": bytes(pkt), "capture_source": "tc_ingress_raw" if capture_metadata else "af_packet", "packet_id": capture_metadata.get("packet_id") if capture_metadata else None, "skb_mark": capture_metadata.get("skb_mark") if capture_metadata else None, "src_mac": None, "dst_mac": None, "eth_type_raw": None, "eth_type": None, "vlan_id": None, "protocol_raw": None, "protocol": None, "protocol_name": None, "src_ip": None, "dst_ip": None, "src_port": None, "dst_port": None, "app_protocol": None, "app_master_protocol": None, "app_category": None, "app_confidence": None, "app_hostname": None, "app_is_encrypted": None, "app_risk_score": None, "dpi_metadata": None, "capture_metadata": capture_metadata, "ip_id": None, "icmp_type": None, "icmp_code": None, "arp_op": None, "tcp_seq": None, "tcp_ack": None, "tcp_flags": None, "icmp_embedded_src_ip": None, "icmp_embedded_dst_ip": None, "icmp_embedded_protocol": None, "icmp_embedded_src_port": None, "icmp_embedded_dst_port": None, } # Ethernet layer if Ether in pkt: eth = pkt[Ether] pkt_info["src_mac"] = _safe_get_attr(eth, "src") pkt_info["dst_mac"] = _safe_get_attr(eth, "dst") # Base ethertype try: eth_type_raw = int(eth.type) except Exception: eth_type_raw = None # VLAN inner ethertype and vlan id if Dot1Q exists if Dot1Q in pkt: try: inner = int(pkt[Dot1Q].type) if inner: eth_type_raw = inner except Exception: pass try: pkt_info["vlan_id"] = int(pkt[Dot1Q].vlan) except Exception: pkt_info["vlan_id"] = None if eth_type_raw is not None: pkt_info["eth_type_raw"] = eth_type_raw try: pkt_info["eth_type"] = ethertype_from_int(eth_type_raw) except Exception: pkt_info["eth_type"] = EtherTypeEnum.UNKNOWN # ARP if ARP in pkt: arp = pkt[ARP] pkt_info["protocol_name"] = "ARP" pkt_info["src_ip"] = _safe_get_attr(arp, "psrc") pkt_info["dst_ip"] = _safe_get_attr(arp, "pdst") try: pkt_info["arp_op"] = int(_safe_get_attr(arp, "op")) except Exception: pkt_info["arp_op"] = None pkt_info["src_port"] = None pkt_info["dst_port"] = None # IPv4 if IP in pkt: ip = pkt[IP] pkt_info["src_ip"] = _safe_get_attr(ip, "src") pkt_info["dst_ip"] = _safe_get_attr(ip, "dst") try: pkt_info["ip_id"] = int(_safe_get_attr(ip, "id")) except Exception: pkt_info["ip_id"] = None try: proto_num = int(_safe_get_attr(ip, "proto")) except Exception: proto_num = None if proto_num is not None: pkt_info["protocol_raw"] = proto_num try: pkt_info["protocol"] = protocol_from_number(proto_num) except Exception: pkt_info["protocol"] = proto_num if proto_num == 6 and TCP in pkt: pkt_info["protocol_name"] = "TCP" pkt_info["src_port"] = _safe_get_attr(pkt[TCP], "sport") pkt_info["dst_port"] = _safe_get_attr(pkt[TCP], "dport") try: pkt_info["tcp_seq"] = int(_safe_get_attr(pkt[TCP], "seq")) pkt_info["tcp_ack"] = int(_safe_get_attr(pkt[TCP], "ack")) pkt_info["tcp_flags"] = int(_safe_get_attr(pkt[TCP], "flags")) except Exception: pass elif proto_num == 17 and UDP in pkt: pkt_info["protocol_name"] = "UDP" pkt_info["src_port"] = _safe_get_attr(pkt[UDP], "sport") pkt_info["dst_port"] = _safe_get_attr(pkt[UDP], "dport") elif proto_num == 1 and ICMP in pkt: pkt_info["protocol_name"] = "ICMP" try: pkt_info["icmp_type"] = int(_safe_get_attr(pkt[ICMP], "type")) pkt_info["icmp_code"] = int(_safe_get_attr(pkt[ICMP], "code")) except Exception: pass else: if pkt_info.get("protocol_name") is None: pkt_info["protocol_name"] = f"IP_PROTO_{proto_num}" if proto_num is not None else None # IPv6 if IPv6 in pkt: ip6 = pkt[IPv6] pkt_info["src_ip"] = _safe_get_attr(ip6, "src") pkt_info["dst_ip"] = _safe_get_attr(ip6, "dst") try: nh = int(_safe_get_attr(ip6, "nh")) except Exception: nh = None if nh is not None: pkt_info["protocol_raw"] = nh try: pkt_info["protocol"] = protocol_from_number(nh) except Exception: pkt_info["protocol"] = nh if nh == 6 and TCP in pkt: pkt_info["protocol_name"] = "TCP" pkt_info["src_port"] = _safe_get_attr(pkt[TCP], "sport") pkt_info["dst_port"] = _safe_get_attr(pkt[TCP], "dport") try: pkt_info["tcp_seq"] = int(_safe_get_attr(pkt[TCP], "seq")) pkt_info["tcp_ack"] = int(_safe_get_attr(pkt[TCP], "ack")) pkt_info["tcp_flags"] = int(_safe_get_attr(pkt[TCP], "flags")) except Exception: pass elif nh == 17 and UDP in pkt: pkt_info["protocol_name"] = "UDP" pkt_info["src_port"] = _safe_get_attr(pkt[UDP], "sport") pkt_info["dst_port"] = _safe_get_attr(pkt[UDP], "dport") elif ICMPv6Unknown in pkt: pkt_info["protocol_name"] = "ICMPv6" try: pkt_info["icmp_type"] = int(_safe_get_attr(pkt[ICMPv6Unknown], "type")) pkt_info["icmp_code"] = int(_safe_get_attr(pkt[ICMPv6Unknown], "code")) except Exception: pass else: if pkt_info.get("protocol_name") is None: pkt_info["protocol_name"] = f"IPV6_PROTO_{nh}" if nh is not None else None # Raw fallback label if Raw in pkt and not pkt_info.get("protocol_name"): pkt_info["protocol_name"] = "RAW" # Packet-level enrichment using tshark, if available. try: packet_info = tshark_manager.lookup_packet(pkt, pkt_iface) if packet_info: _merge_enrichment(pkt_info, packet_info) except Exception: logger.exception("tshark enrichment failed") if ICMP in pkt: inner = pkt[ICMP].payload if inner and IP in inner: inner_ip = inner[IP] pkt_info["icmp_embedded_src_ip"] = _safe_get_attr(inner_ip, "src") pkt_info["icmp_embedded_dst_ip"] = _safe_get_attr(inner_ip, "dst") try: pkt_info["icmp_embedded_protocol"] = int(_safe_get_attr(inner_ip, "proto")) except Exception: pkt_info["icmp_embedded_protocol"] = None if TCP in inner: pkt_info["icmp_embedded_src_port"] = _safe_get_attr(inner[TCP], "sport") pkt_info["icmp_embedded_dst_port"] = _safe_get_attr(inner[TCP], "dport") elif UDP in inner: pkt_info["icmp_embedded_src_port"] = _safe_get_attr(inner[UDP], "sport") pkt_info["icmp_embedded_dst_port"] = _safe_get_attr(inner[UDP], "dport") if pkt_info.get("packet_id"): pkt_info["correlation_key"] = f"pid:{pkt_info['packet_id']}" pkt_info["correlation_source"] = "kernel_mark" else: pkt_info["packet_uid"] = build_packet_uid(pkt_info) pkt_info["correlation_key"] = f"uid:{pkt_info['packet_uid']}" pkt_info["correlation_source"] = "legacy_hash" try: web_loop = getattr(shared_objects, "web_loop", None) web_db = getattr(shared_objects, "db", None) if web_db is not None and web_loop is not None: try: packet_tracker.observe_packet(pkt_info) logger.debug("Tracked packet on %s (len=%d)", pkt_info.get("iface"), pkt_info.get("length")) except Exception as e: logger.exception("Failed to track packet for %s — buffering: %s", pkt_info.get("iface"), e) _PACKET_BUFFER.append(pkt_info) if len(_PACKET_BUFFER) > _BUFFER_CAPACITY: _PACKET_BUFFER.pop(0) else: _PACKET_BUFFER.append(pkt_info) if len(_PACKET_BUFFER) == 1: logger.info("Buffering packets; web DB not ready yet (first buffered packet on %s)", pkt_info.get("iface")) if len(_PACKET_BUFFER) > _BUFFER_CAPACITY: _PACKET_BUFFER.pop(0) logger.warning("Packet buffer full: dropped oldest packet") except Exception: logger.exception("Failed to schedule DB insert (unexpected)") def parse_packet_bytes( packet_bytes: bytes, iface: str, capture_metadata: Optional[Dict[str, Any]] = None, ) -> None: """Parse one raw Ethernet frame using the shared Scapy packet path.""" pkt = Ether(packet_bytes) pkt.sniffed_on = iface parse_packet(pkt, iface, capture_metadata=capture_metadata) # ------------------------- # AF_PACKET socket utilities # ------------------------- def _create_af_packet_socket(ifname: str, rx_buf: Optional[int] = None) -> Optional[socket.socket]: """ Create and bind an AF_PACKET raw socket to interface. Non-blocking socket returned or None on failure. """ try: s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0003)) # ETH_P_ALL except PermissionError: logger.exception("Permission denied creating AF_PACKET socket for %s", ifname) return None except Exception: logger.exception("Failed creating AF_PACKET socket for %s", ifname) return None try: s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rx_buf or settings.sniffer_socket_rcvbuf_bytes) except Exception: logger.debug("SO_RCVBUF set failed for %s (non-fatal)", ifname) # Try TPACKET_V3 best-effort try: SOL_PACKET = getattr(socket, "SOL_PACKET", 263) PACKET_VERSION = getattr(socket, "PACKET_VERSION", 10) TPACKET_V3 = 3 s.setsockopt(SOL_PACKET, PACKET_VERSION, struct.pack("I", TPACKET_V3)) logger.debug("Requested TPACKET_V3 on %s", ifname) except Exception: logger.debug("TPACKETv3 not available for %s", ifname) try: s.bind((ifname, 0)) except OSError as e: logger.exception("Bind failed for %s: %s", ifname, e) s.close() return None s.setblocking(False) logger.info("AF_PACKET socket bound to %s (fd=%s)", ifname, getattr(s, "fileno", lambda: "?")()) return s def _close_socket_in_dict(sockets: Dict[str, socket.socket], ifname: str) -> None: s = sockets.pop(ifname, None) if s: try: s.close() except Exception: pass def _ensure_socket_for_session(sockets: Dict[str, socket.socket], iface: str, bridge_label: str) -> None: """ Ensure we have an AF_PACKET socket in the given sockets dict for iface. If socket doesn't exist, attempt to create it. bridge_label is used for logging/context. """ if iface in sockets: return if not check_interface_exists(iface): logger.warning("Interface %s does not exist; skipping socket creation", iface) return s = _create_af_packet_socket(iface) if s: sockets[iface] = s logger.info("AF_PACKET socket created for %s (label=%s)", iface, bridge_label) else: logger.warning("Failed to create AF_PACKET socket for %s (label=%s)", iface, bridge_label) def _sync_bridge_telemetry() -> None: interfaces = sorted( { iface for session in sessions.values() if session.get("is_bridge") for iface in session.get("ports", []) } ) try: bridge_telemetry_manager.update_interfaces(interfaces) except Exception: logger.exception("Failed to update bridge telemetry collector") active_enrichment_ifaces = sorted( { iface for session in sessions.values() for iface in ( list(session.get("capture_ifaces", [])) + (list(session.get("ports", [])) if session.get("is_bridge") else []) ) if iface } ) try: tshark_manager.update_interfaces(active_enrichment_ifaces) except Exception: logger.exception("Failed to update tshark enrichment workers") # ------------------------- # Per-session reader loop # ------------------------- def _session_reader_loop(session_id: str) -> None: session = sessions.get(session_id) if not session: logger.warning("Session %s not found at reader start", session_id) return stop_event: threading.Event = session["stop_event"] sockets: Dict[str, socket.socket] = session["sockets"] label: str = session["label"] logger.info("Session %s reader starting (label=%s)", session_id, label) sel = selectors.DefaultSelector() # register existing sockets for iface, s in list(sockets.items()): try: sel.register(s, selectors.EVENT_READ, data=iface) except Exception: logger.debug("Register failed for %s in session %s (continuing)", iface, session_id) last_drain_ts = time.time() try: while not stop_event.is_set(): # ensure newly created sockets are registered for iface, s in list(sockets.items()): try: if not any(k.fileobj is s for k in sel.get_map().values()): sel.register(s, selectors.EVENT_READ, data=iface) except Exception: pass try: events = sel.select(timeout=settings.sniffer_selector_timeout_seconds) except Exception: logger.exception("Selector error in session %s", session_id) time.sleep(0.1) continue if not events: # occasionally attempt to drain buffer if DB becomes available if time.time() - last_drain_ts > settings.sniffer_buffer_drain_interval_seconds: try: drain_buffer_to_shared_db() except Exception: logger.exception("Periodic drain failed in session %s", session_id) last_drain_ts = time.time() continue for key, _ in events: sock: socket.socket = key.fileobj iface: str = key.data try: raw = sock.recv(settings.sniffer_recv_bytes) if not raw: continue except BlockingIOError: continue except OSError as e: if e.errno in (errno.ENODEV, errno.ENETDOWN, errno.EBADF): logger.warning("Socket error on %s in session %s: %s — closing", iface, session_id, e) try: sel.unregister(sock) except Exception: pass _close_socket_in_dict(sockets, iface) continue else: logger.exception("Recv error on %s in session %s", iface, session_id) continue # parse with scapy try: pkt = Ether(raw) pkt.sniffed_on = iface parse_packet(pkt, label) logger.debug("Captured packet on %s in session %s (len=%d)", iface, session_id, len(raw)) except Exception: logger.exception("Failed to parse/process packet from %s in session %s", iface, session_id) continue finally: logger.info("Session %s reader stopping; cleaning up sockets", session_id) # unregister and close sockets try: for key in list(sel.get_map().values()): try: sel.unregister(key.fileobj) except Exception: pass except Exception: pass for iface in list(sockets.keys()): _close_socket_in_dict(sockets, iface) try: sel.close() except Exception: pass logger.info("Session %s reader stopped", session_id) # ------------------------- # Public API: start/stop/status # ------------------------- def start_afpacket_sniffer(target: str, target_is_interface: bool = False) -> str: """ Start a sniffer session. Returns session_id string. If target_is_interface == False: target is treated as bridge name and ports are snapshotted. If target_is_interface == True: target is treated as interface name. """ session_id = str(uuid4()) session: Dict[str, Any] = { "stop_event": threading.Event(), "sockets": {}, "label": target, "is_bridge": not target_is_interface, "ports": [], "capture_ifaces": [], } # determine ports for this session if target_is_interface: ports = [target] capture_ifaces = [target] else: ports = get_bridge_ports_once(target) capture_ifaces = [] session["ports"] = ports session["capture_ifaces"] = capture_ifaces sessions[session_id] = session # create sockets for this session only try: for iface in capture_ifaces: if not check_interface_exists(iface): logger.warning("Snapshot port %s missing for session %s, skipping", iface, session_id) continue s = _create_af_packet_socket(iface) if s: session["sockets"][iface] = s except Exception: sessions.pop(session_id, None) raise # start dedicated thread if capture_ifaces: t = threading.Thread(target=_session_reader_loop, args=(session_id,), daemon=True) session["thread"] = t t.start() else: session["thread"] = None _sync_bridge_telemetry() logger.info( "Started sniffer session %s label=%s ports=%s capture_ifaces=%s", session_id, target, ports, capture_ifaces, ) return session_id def stop_afpacket_sniffer(session_id: Optional[str] = None, target: Optional[str] = None, target_is_interface: bool = False) -> None: """ Stop either a specific session by session_id (preferred), or stop sockets associated with target. - If session_id provided: stop that session and clean up. - Else if target provided: remove sockets associated with target from sessions (close sockets). - Else if no args: stop all sessions (global stop). """ # Stop by session_id if session_id: s = sessions.pop(session_id, None) if not s: logger.warning("Requested stop for unknown session %s", session_id) return discarded_ifaces = list({*s.get("ports", []), *s.get("sockets", {}).keys()}) s["stop_event"].set() t = s.get("thread") if t and isinstance(t, threading.Thread): t.join(timeout=settings.sniffer_thread_join_timeout_seconds) _sync_bridge_telemetry() try: discarded = packet_tracker.discard_entries_for_ifaces(discarded_ifaces) if discarded: logger.info("Discarded %s pending packet-tracker entries for stopped session %s", discarded, session_id) except Exception: logger.exception("Failed to discard packet-tracker entries for session %s", session_id) logger.info("Stopped session %s", session_id) return # Stop by target (interface or bridge) if target: # For interface-mode: close that iface in any session that has it for sid, s in list(sessions.items()): if not target_is_interface and s.get("is_bridge") and s.get("label") == target: stop_afpacket_sniffer(session_id=sid) continue if target_is_interface and not s.get("is_bridge") and s.get("label") == target: stop_afpacket_sniffer(session_id=sid) continue if target in s.get("ports", []): # close only that iface socket in that session sock = s["sockets"].pop(target, None) if sock: try: sock.close() except Exception: pass try: s["ports"].remove(target) except Exception: pass try: s["capture_ifaces"].remove(target) except Exception: pass logger.info("Removed target %s from session %s", target, sid) _sync_bridge_telemetry() return # Global stop: stop all sessions for sid in list(sessions.keys()): stop_afpacket_sniffer(session_id=sid) # attempt to close db pool if shared.web_loop is available; otherwise leave to main try: web_loop = getattr(shared_objects, "web_loop", None) web_db = getattr(shared_objects, "db", None) if web_db is not None and web_loop is not None: asyncio.run_coroutine_threadsafe(web_db.close_pool(), web_loop) except Exception: logger.exception("Failed to schedule DB pool close") logger.info("All sniffer sessions stopped") try: bridge_telemetry_manager.stop() except Exception: logger.exception("Failed to stop bridge telemetry collector") def get_sniffer_status() -> Dict[str, Dict[str, object]]: """ Return simple status per managed interface, including which session owns it. Output format: { "": {"running": True, "exists": True, "up": True, "session_id": "", "session_label": "..."} } """ out: Dict[str, Dict[str, object]] = {} # build map iface -> session for sid, s in sessions.items(): running = s.get("thread") is not None and s.get("thread").is_alive() for iface in s.get("sockets", {}).keys(): out[iface] = { "running": running, "exists": check_interface_exists(iface), "up": check_interface_up(iface), "session_id": sid, "session_label": s.get("label"), } if not s.get("sockets") and s.get("is_bridge"): for iface in s.get("ports", []): out[iface] = { "running": not s.get("stop_event").is_set(), "exists": check_interface_exists(iface), "up": check_interface_up(iface), "session_id": sid, "session_label": s.get("label"), } return out def get_internal_debug_state() -> dict: """ Return internal debug information. """ return { "sessions": { sid: { "label": s.get("label"), "is_bridge": s.get("is_bridge"), "ports": list(s.get("ports", [])), "capture_ifaces": list(s.get("capture_ifaces", [])), "sockets": list(s.get("sockets", {}).keys()), "thread_alive": bool(s.get("thread") and s.get("thread").is_alive()), "active": not s.get("stop_event").is_set(), } for sid, s in sessions.items() }, "buffer_len": len(_PACKET_BUFFER), "bridge_capture_mode": "tc_ingress_raw" if any(s.get("is_bridge") for s in sessions.values()) else "af_packet", "telemetry_ports": sorted({iface for session in sessions.values() for iface in session.get("ports", [])}), "tshark": tshark_manager.get_debug_snapshot(), "packet_tracker": packet_tracker.get_debug_snapshot(), }