fix: optimize bridge port handling by using a fixed snapshot to reduce sysfs reads
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s

This commit is contained in:
2025-11-29 13:45:44 +01:00
parent 24a47fdf37
commit f1da233a50

View File

@@ -41,8 +41,10 @@ af_selector: Optional[selectors.BaseSelector] = None
af_thread: Optional[threading.Thread] = None af_thread: Optional[threading.Thread] = None
af_stop_event: Optional[threading.Event] = None af_stop_event: Optional[threading.Event] = None
# Cache of bridge -> ports # Snapshot of bridge -> ports taken once when sniffer starts
bridge_ports_cache: Dict[str, List[str]] = {} fixed_bridge_ports: Dict[str, List[str]] = {}
# Remember which bridge the sniffer is using (single active sniffer model)
current_bridge: Optional[str] = None
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# Async loop used to schedule DB inserts from packet callback threads. # Async loop used to schedule DB inserts from packet callback threads.
@@ -88,40 +90,41 @@ def check_interface_up(iface: str) -> bool:
return False return False
def get_bridge_ports(bridge: str) -> List[str]: def _read_bridge_ports_from_sysfs(bridge: str) -> List[str]:
""" """
Read the bridge member interfaces from sysfs (/sys/class/net/<bridge>/brif/). Read bridge member interfaces from sysfs. Internal helper that always reads.
This function refreshes the cache each call (cache entry is updated),
which keeps behavior predictable for dynamic topologies.
If the bridge does not exist, an empty list is returned.
""" """
base = f"/sys/class/net/{bridge}/brif/" base = f"/sys/class/net/{bridge}/brif/"
if not os.path.isdir(base): if not os.path.isdir(base):
logger.error("Bridge '%s' does not exist", bridge)
return [] return []
try: try:
ports = [p for p in os.listdir(base) if check_interface_exists(p)] ports = [p for p in os.listdir(base) if check_interface_exists(p)]
except Exception as e: except Exception:
logger.exception("Error listing bridge ports for %s: %s", bridge, e) logger.exception("Error reading bridge ports for %s", bridge)
ports = [] ports = []
# update local cache and log discovered ports
bridge_ports_cache[bridge] = ports
logger.info("Bridge %s ports: %s", bridge, ports)
return ports return ports
def get_bridge_ports_once(bridge: str) -> List[str]:
"""
Convenience: read bridge ports (no caching here) used when preparing the fixed snapshot.
"""
return _read_bridge_ports_from_sysfs(bridge)
def determine_direction(pkt_iface: str, bridge: str): def determine_direction(pkt_iface: str, bridge: str):
""" """
Determine ingress and egress information for a packet based on the Determine ingress/egress using the fixed snapshot of bridge ports
interface it was sniffed on and the bridge port list. taken when the sniffer started. This avoids reading sysfs for every
packet and prevents noisy logs when bridge ports are static.
Returns: If snapshot is missing (defensive), fall back to a direct sysfs read.
ingress: the interface where the packet was observed
egress: list of other bridge ports (possible egress ports)
""" """
ports = bridge_ports_cache.get(bridge) or get_bridge_ports(bridge) ports = fixed_bridge_ports.get(bridge)
if ports is None:
# Defensive fallback (should not happen if start_afpacket_sniffer was used)
ports = _read_bridge_ports_from_sysfs(bridge)
ingress = pkt_iface ingress = pkt_iface
egress = [p for p in ports if p != pkt_iface] egress = [p for p in ports if p != pkt_iface]
return ingress, egress return ingress, egress
@@ -184,7 +187,7 @@ async def db_insert_packet(pkt_info: dict) -> None:
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# Packet parsing using your existing parse_packet logic # Packet parsing callback used by scapy-like parser
# ------------------------------------------------------------------- # -------------------------------------------------------------------
def parse_packet(pkt, bridge: str) -> None: def parse_packet(pkt, bridge: str) -> None:
""" """
@@ -348,55 +351,34 @@ def _close_socket(ifname: str) -> None:
pass pass
def _ensure_sockets_for_bridge(bridge: str) -> None: def _ensure_sockets_for_bridge_from_snapshot(bridge: str) -> None:
""" """
Ensure there is an AF_PACKET socket bound for every current bridge port. Create sockets for ports listed in the fixed snapshot.
We keep sockets for interfaces even if they are DOWN (they will start receiving when link comes up). Only creates sockets for ports that don't already have one.
""" """
ports = get_bridge_ports(bridge) ports = fixed_bridge_ports.get(bridge, [])
for p in ports: for p in ports:
if p in af_sockets: if p in af_sockets:
continue continue
if not check_interface_exists(p): if not check_interface_exists(p):
logger.warning("Snapshot port %s does not exist in sysfs (skipping)", p)
continue continue
s = _create_af_packet_socket(p) s = _create_af_packet_socket(p)
if s: if s:
af_sockets[p] = s af_sockets[p] = s
# register later in selector by reader thread
def _rebind_if_needed(ifname: str) -> None:
"""
Try to re-create a socket for an interface if it is missing (e.g. after deletion).
"""
if ifname in af_sockets:
return
if not check_interface_exists(ifname):
return
s = _create_af_packet_socket(ifname)
if s:
af_sockets[ifname] = s
if af_selector:
try:
af_selector.register(s, selectors.EVENT_READ, data=ifname)
except Exception:
# ignore duplicate registration / race
pass
def afpacket_reader_loop(bridge: str, stop_event: threading.Event) -> None: def afpacket_reader_loop(bridge: str, stop_event: threading.Event) -> None:
""" """
Single reader thread that multiplexes all AF_PACKET sockets for the bridge Single reader thread that multiplexes all AF_PACKET sockets for the bridge
using a selector. When data arrives, we parse into a Scapy packet and call parse_packet(). using a selector. When data arrives, we parse into a Scapy packet and call parse_packet().
This design uses a single thread + selector rather than one thread per interface.
""" """
global af_selector global af_selector
logger.info("AF_PACKET reader starting for bridge %s", bridge) logger.info("AF_PACKET reader starting for bridge %s", bridge)
af_selector = selectors.DefaultSelector() af_selector = selectors.DefaultSelector()
# ensure sockets exist for current ports # Ensure sockets exist based on the fixed snapshot (created at start)
_ensure_sockets_for_bridge(bridge) _ensure_sockets_for_bridge_from_snapshot(bridge)
# register sockets we have # register sockets we have
for ifname, s in list(af_sockets.items()): for ifname, s in list(af_sockets.items()):
@@ -410,20 +392,17 @@ def afpacket_reader_loop(bridge: str, stop_event: threading.Event) -> None:
# main loop # main loop
while not stop_event.is_set(): while not stop_event.is_set():
# refresh sockets for any new bridge ports (cheap) # In this static snapshot mode we only occasionally try to register
# newly created sockets (e.g., interfaces that existed but socket creation failed earlier).
try: try:
_ensure_sockets_for_bridge(bridge)
# register any new sockets with selector
for ifname, s in list(af_sockets.items()): for ifname, s in list(af_sockets.items()):
try: try:
# register only if not registered
if not any(k.fileobj is s for k in af_selector.get_map().values()): if not any(k.fileobj is s for k in af_selector.get_map().values()):
af_selector.register(s, selectors.EVENT_READ, data=ifname) af_selector.register(s, selectors.EVENT_READ, data=ifname)
except Exception: except Exception:
# ignore races
pass pass
except Exception: except Exception:
logger.exception("Error ensuring sockets") logger.exception("Error ensuring selector registrations")
# wait for events with short timeout to remain responsive # wait for events with short timeout to remain responsive
try: try:
@@ -434,30 +413,27 @@ def afpacket_reader_loop(bridge: str, stop_event: threading.Event) -> None:
continue continue
if not events: if not events:
# no events; loop will re-ensure sockets again # no events; loop will re-check registrations
continue continue
for key, mask in events: for key, mask in events:
sock: socket.socket = key.fileobj sock: socket.socket = key.fileobj
ifname: str = key.data ifname: str = key.data
try: try:
# read raw frame
# using a single large buffer; AF_PACKET will give full frame
raw = sock.recv(65536) raw = sock.recv(65536)
if not raw: if not raw:
continue continue
except BlockingIOError: except BlockingIOError:
continue continue
except OSError as e: except OSError as e:
# handle interface removal (ENODEV) or other errors: close socket and attempt rebind later # handle interface removal (ENODEV) or other errors: close socket and continue
if e.errno in (errno.ENODEV, errno.ENETDOWN, errno.EBADF): if e.errno in (errno.ENODEV, errno.ENETDOWN, errno.EBADF):
logger.warning("Socket error on %s: %s — closing socket and will attempt to rebind later", ifname, e) logger.warning("Socket error on %s: %s — closing socket", ifname, e)
try: try:
af_selector.unregister(sock) af_selector.unregister(sock)
except Exception: except Exception:
pass pass
_close_socket(ifname) _close_socket(ifname)
# schedule rebind attempt on next loop iteration
continue continue
else: else:
logger.exception("Recv error on %s: %s", ifname, e) logger.exception("Recv error on %s: %s", ifname, e)
@@ -502,15 +478,19 @@ def afpacket_reader_loop(bridge: str, stop_event: threading.Event) -> None:
def start_afpacket_sniffer(bridge: str) -> None: def start_afpacket_sniffer(bridge: str) -> None:
""" """
Start the optimized AF_PACKET sniffer for the provided bridge. Start the optimized AF_PACKET sniffer for the provided bridge.
This will bind sockets to all current bridge ports and start a single reader thread. This reads bridge ports once (snapshot) and starts a single reader thread.
""" """
global af_thread, af_stop_event global af_thread, af_stop_event, current_bridge, fixed_bridge_ports
if af_thread and af_thread.is_alive(): if af_thread and af_thread.is_alive():
logger.info("AF_PACKET sniffer already running") logger.info("AF_PACKET sniffer already running")
return return
# ensure we have initial ports # Warm and freeze the bridge ports snapshot. We read sysfs once here.
get_bridge_ports(bridge) ports = get_bridge_ports_once(bridge)
fixed_bridge_ports[bridge] = ports
current_bridge = bridge
logger.info("Fixed bridge ports for %s: %s", bridge, ports)
af_stop_event = threading.Event() af_stop_event = threading.Event()
af_thread = threading.Thread(target=afpacket_reader_loop, args=(bridge, af_stop_event), daemon=True) af_thread = threading.Thread(target=afpacket_reader_loop, args=(bridge, af_stop_event), daemon=True)
af_thread.start() af_thread.start()
@@ -519,9 +499,9 @@ def start_afpacket_sniffer(bridge: str) -> None:
def stop_afpacket_sniffer() -> None: def stop_afpacket_sniffer() -> None:
""" """
Stop the AF_PACKET sniffer thread and close sockets. Stop the AF_PACKET sniffer thread and close sockets. Clear the fixed snapshot.
""" """
global af_thread, af_stop_event global af_thread, af_stop_event, current_bridge, fixed_bridge_ports
if not af_thread: if not af_thread:
return return
if af_stop_event: if af_stop_event:
@@ -529,6 +509,12 @@ def stop_afpacket_sniffer() -> None:
af_thread.join(timeout=2) af_thread.join(timeout=2)
af_thread = None af_thread = None
af_stop_event = None af_stop_event = None
# clear fixed snapshot(s)
if current_bridge:
fixed_bridge_ports.pop(current_bridge, None)
current_bridge = None
logger.info("AF_PACKET sniffer stopped") logger.info("AF_PACKET sniffer stopped")