All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 11s
- Deleted flow_identity.py and nfstream_flow_worker.py as they are no longer needed. - Removed nfstream_manager.py and its associated logic for managing NFStream workers. - Added tshark_manager.py to manage tshark packet enrichment and matching. - Updated setup_build_server.sh to include default environment variables for tshark. - Implemented packet signature generation and enrichment logic in the new TsharkManager class.
845 lines
30 KiB
Python
845 lines
30 KiB
Python
# 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:
|
|
{
|
|
"<iface>": {"running": True, "exists": True, "up": True, "session_id": "<uuid>", "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(),
|
|
}
|