diff --git a/backend/src/api/sniffer_api.py b/backend/src/api/sniffer_api.py index 3da4566..e50f028 100644 --- a/backend/src/api/sniffer_api.py +++ b/backend/src/api/sniffer_api.py @@ -6,6 +6,8 @@ from fastapi import APIRouter, Body, HTTPException, Query from pydantic import BaseModel, Field from src.network_sniffer import ( + BRIDGE_CAPTURE_MODE_AF_PACKET, + BRIDGE_CAPTURE_MODE_TC_EBPF, get_internal_debug_state, get_capture_session_status, start_capture_session, @@ -28,6 +30,11 @@ class SnifferStartRequest(BaseModel): example="eth0", description="Interface name to sniff.", ) + bridge_capture_mode: Optional[str] = Field( + None, + example="tc_ebpf", + description="Bridge capture mode: 'tc_ebpf' or 'af_packet'. Ignored for interface capture.", + ) class SnifferStartResponse(BaseModel): @@ -37,6 +44,7 @@ class SnifferStartResponse(BaseModel): session_id: str = Field(..., description="Unique session identifier.") target: str = Field(..., description="Started target name.") target_type: str = Field(..., description="Either 'bridge' or 'interface'.") + capture_mode: str = Field(..., description="The effective capture mode used by the session.") class SnifferStopRequest(BaseModel): @@ -62,6 +70,7 @@ class InterfaceSnifferStatus(BaseModel): up: bool = Field(..., description="Whether the interface is operationally up.") session_id: Optional[str] = Field(None, description="Owning capture session ID.") session_label: Optional[str] = Field(None, description="Human-readable session label.") + capture_mode: Optional[str] = Field(None, description="Capture mode used by the owning session.") class SnifferStatusResponse(BaseModel): @@ -87,14 +96,23 @@ def sniffer_start(req: SnifferStartRequest) -> SnifferStartResponse: session_id=session_id, target=req.interface, target_type="interface", + capture_mode="af_packet", ) - session_id = start_capture_session(req.bridge, target_is_interface=False) + effective_capture_mode = req.bridge_capture_mode or BRIDGE_CAPTURE_MODE_TC_EBPF + if effective_capture_mode not in {BRIDGE_CAPTURE_MODE_TC_EBPF, BRIDGE_CAPTURE_MODE_AF_PACKET}: + raise HTTPException(status_code=400, detail="bridge_capture_mode must be 'tc_ebpf' or 'af_packet'") + session_id = start_capture_session( + req.bridge, + target_is_interface=False, + bridge_capture_mode=effective_capture_mode, + ) return SnifferStartResponse( started=True, session_id=session_id, target=req.bridge, target_type="bridge", + capture_mode=effective_capture_mode, ) except Exception as exc: raise HTTPException(status_code=500, detail=f"Failed to start sniffer: {exc}") from exc diff --git a/backend/src/network_sniffer.py b/backend/src/network_sniffer.py index 1310ff5..5d5159b 100644 --- a/backend/src/network_sniffer.py +++ b/backend/src/network_sniffer.py @@ -56,6 +56,9 @@ sessions: Dict[str, Dict[str, Any]] = {} # "ports": List[str], # } +BRIDGE_CAPTURE_MODE_TC_EBPF = "tc_ebpf" +BRIDGE_CAPTURE_MODE_AF_PACKET = "af_packet" + # ------------------------- # PacketInfo typing # ------------------------- @@ -545,7 +548,7 @@ def _sync_bridge_telemetry() -> None: bridge_session_interfaces = { session_id: list(session.get("ports", [])) for session_id, session in sessions.items() - if session.get("is_bridge") + if session.get("is_bridge") and session.get("capture_mode") == BRIDGE_CAPTURE_MODE_TC_EBPF } try: bridge_telemetry_manager.update_sessions(bridge_session_interfaces) @@ -558,7 +561,11 @@ def _sync_bridge_telemetry() -> None: for session in sessions.values() for iface in ( list(session.get("capture_ifaces", [])) - + (list(session.get("ports", [])) if session.get("is_bridge") else []) + + ( + list(session.get("ports", [])) + if session.get("is_bridge") and session.get("capture_mode") == BRIDGE_CAPTURE_MODE_TC_EBPF + else [] + ) ) if iface } @@ -677,20 +684,31 @@ def _session_reader_loop(session_id: str) -> None: # ------------------------- # Public API: start/stop/status # ------------------------- -def start_capture_session(target: str, target_is_interface: bool = False) -> str: +def start_capture_session( + target: str, + target_is_interface: bool = False, + bridge_capture_mode: str = BRIDGE_CAPTURE_MODE_TC_EBPF, +) -> str: """ Start a packet capture session. Returns session_id string. If target_is_interface == True, capture uses an AF_PACKET raw socket on that interface. If target_is_interface == False, target is treated as a bridge and capture uses the - tc/eBPF bridge telemetry path for the bridge ports. + configured bridge capture mode for the bridge ports. """ + effective_capture_mode = ( + BRIDGE_CAPTURE_MODE_AF_PACKET + if target_is_interface or bridge_capture_mode == BRIDGE_CAPTURE_MODE_AF_PACKET + else BRIDGE_CAPTURE_MODE_TC_EBPF + ) + session_id = str(uuid4()) session: Dict[str, Any] = { "stop_event": threading.Event(), "sockets": {}, "label": target, "is_bridge": not target_is_interface, + "capture_mode": effective_capture_mode, "ports": [], "capture_ifaces": [], } @@ -701,7 +719,7 @@ def start_capture_session(target: str, target_is_interface: bool = False) -> str capture_ifaces = [target] else: ports = get_bridge_ports_once(target) - capture_ifaces = [] + capture_ifaces = ports if effective_capture_mode == BRIDGE_CAPTURE_MODE_AF_PACKET else [] session["ports"] = ports session["capture_ifaces"] = capture_ifaces @@ -729,9 +747,10 @@ def start_capture_session(target: str, target_is_interface: bool = False) -> str session["thread"] = None _sync_bridge_telemetry() logger.info( - "Started capture session %s label=%s ports=%s capture_ifaces=%s", + "Started capture session %s label=%s capture_mode=%s ports=%s capture_ifaces=%s", session_id, target, + effective_capture_mode, ports, capture_ifaces, ) @@ -839,6 +858,7 @@ def get_capture_session_status() -> Dict[str, Dict[str, object]]: "up": check_interface_up(iface), "session_id": sid, "session_label": s.get("label"), + "capture_mode": s.get("capture_mode"), } if not s.get("sockets") and s.get("is_bridge"): for iface in s.get("ports", []): @@ -848,6 +868,7 @@ def get_capture_session_status() -> Dict[str, Dict[str, object]]: "up": check_interface_up(iface), "session_id": sid, "session_label": s.get("label"), + "capture_mode": s.get("capture_mode"), } return out @@ -861,6 +882,7 @@ def get_internal_debug_state() -> dict: sid: { "label": s.get("label"), "is_bridge": s.get("is_bridge"), + "capture_mode": s.get("capture_mode"), "ports": list(s.get("ports", [])), "capture_ifaces": list(s.get("capture_ifaces", [])), "sockets": list(s.get("sockets", {}).keys()), @@ -870,16 +892,35 @@ def get_internal_debug_state() -> dict: 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", [])}), + "bridge_capture_mode": ( + "tc_ingress_raw" + if any(s.get("is_bridge") and s.get("capture_mode") == BRIDGE_CAPTURE_MODE_TC_EBPF for s in sessions.values()) + else "af_packet" + ), + "telemetry_ports": sorted( + { + iface + for session in sessions.values() + if session.get("capture_mode") == BRIDGE_CAPTURE_MODE_TC_EBPF + for iface in session.get("ports", []) + } + ), "tshark": tshark_manager.get_debug_snapshot(), "packet_tracker": packet_tracker.get_debug_snapshot(), } -def start_afpacket_sniffer(target: str, target_is_interface: bool = False) -> str: +def start_afpacket_sniffer( + target: str, + target_is_interface: bool = False, + bridge_capture_mode: str = BRIDGE_CAPTURE_MODE_TC_EBPF, +) -> str: """Backward-compatible wrapper for start_capture_session().""" - return start_capture_session(target, target_is_interface=target_is_interface) + return start_capture_session( + target, + target_is_interface=target_is_interface, + bridge_capture_mode=bridge_capture_mode, + ) def stop_afpacket_sniffer( diff --git a/frontend/src/components/SnifferManager.tsx b/frontend/src/components/SnifferManager.tsx index d4ba57c..a0f22cd 100644 --- a/frontend/src/components/SnifferManager.tsx +++ b/frontend/src/components/SnifferManager.tsx @@ -43,6 +43,7 @@ interface SnifferManagerProps { export default function SnifferManager(props: SnifferManagerProps): ReactElement { const [isModalOpen, setIsModalOpen] = useState(false); const [startMode, setStartMode] = useState<'interface' | 'bridge'>('interface'); + const [bridgeCaptureMode, setBridgeCaptureMode] = useState<'tc_ebpf' | 'af_packet'>('tc_ebpf'); const [form] = Form.useForm(); const statusEntries = useMemo( @@ -53,6 +54,7 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement const onOpenStartModal = () => { form.resetFields(); setStartMode('interface'); + setBridgeCaptureMode('tc_ebpf'); setIsModalOpen(true); }; @@ -60,27 +62,30 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement setIsModalOpen(false); }; - const handleStartSubmit = async (values: { target?: string }) => { + const handleStartSubmit = async (values: { target?: string; bridgeCaptureMode?: 'tc_ebpf' | 'af_packet' }) => { const target = values.target; if (!target) { - notification.warning({ message: 'Warning', description: 'Please select a target to start sniffing on.' }); + notification.warning({ message: 'Warning', description: 'Please select a target to start capture on.' }); return; } try { - const payload = startMode === 'interface' ? { interface: target } : { bridge: target }; + const payload = + startMode === 'interface' + ? { interface: target } + : { bridge: target, bridge_capture_mode: values.bridgeCaptureMode ?? bridgeCaptureMode }; const result = await startSniffer(payload); notification.success({ - message: 'Sniffer started', - description: `Sniffer started on ${target} (session ${result.session_id})`, + message: 'Capture started', + description: `Capture started on ${target} via ${result.capture_mode} (session ${result.session_id})`, }); await props.refreshAll(); setIsModalOpen(false); } catch (error: any) { console.error('startSniffer error', error); notification.error({ - message: 'Failed to start sniffer', - description: error?.message ?? 'Failed to start sniffer', + message: 'Failed to start capture', + description: error?.message ?? 'Failed to start capture', }); } }; @@ -88,13 +93,13 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement const handleStopAll = async () => { try { await stopSniffer(); - notification.success({ message: 'All sniffers stopped' }); + notification.success({ message: 'All capture sessions stopped' }); await props.refreshStatus(); } catch (error: any) { console.error('stopSniffer error', error); notification.error({ - message: 'Failed to stop sniffers', - description: error?.message ?? 'Failed to stop sniffers', + message: 'Failed to stop capture sessions', + description: error?.message ?? 'Failed to stop capture sessions', }); } }; @@ -103,8 +108,8 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement try { await stopSnifferByInterface(ifaceName); notification.success({ - message: 'Sniffer stopped', - description: `Sniffer stopped on interface ${ifaceName}`, + message: 'Capture stopped', + description: `Capture stopped on interface ${ifaceName}`, }); await props.refreshStatus(); return; @@ -116,8 +121,8 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement try { await stopSniffer({ session_id: sessionId }); notification.success({ - message: 'Sniffer stopped', - description: `Sniffer stopped on interface ${ifaceName} (session ${sessionId})`, + message: 'Capture stopped', + description: `Capture stopped on interface ${ifaceName} (session ${sessionId})`, }); await props.refreshStatus(); return; @@ -127,8 +132,8 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement } notification.error({ - message: 'Failed to stop sniffer', - description: 'Could not stop sniffer for this interface.', + message: 'Failed to stop capture', + description: 'Could not stop capture for this interface.', }); }; @@ -145,7 +150,7 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement