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
} onClick={() => props.refreshStatus()} />
-
+
} onClick={handleStopAll} />
@@ -189,15 +194,15 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
try {
const result = await startSniffer({ interface: name });
notification.success({
- message: 'Sniffer started',
- description: `Sniffer started on ${name} (session ${result.session_id})`,
+ message: 'Capture started',
+ description: `Capture started on ${name} via ${result.capture_mode} (session ${result.session_id})`,
});
await props.refreshStatus();
} catch (error: any) {
console.error('startSniffer quick', 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',
});
}
}}
@@ -231,6 +236,11 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
)}
+ {status.capture_mode && (
+
+ {status.capture_mode === 'af_packet' ? 'AF_PACKET' : 'tc/eBPF'}
+
+ )}
}
description={interface: {name}}
@@ -256,7 +266,8 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
value={startMode}
onChange={(event) => {
setStartMode(event.target.value);
- form.setFieldsValue({ target: undefined });
+ setBridgeCaptureMode('tc_ebpf');
+ form.setFieldsValue({ target: undefined, bridgeCaptureMode: 'tc_ebpf' });
}}
>
Interface
@@ -290,6 +301,28 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
))}
+
+ {startMode === 'bridge' && (
+
+ {
+ setBridgeCaptureMode(event.target.value);
+ form.setFieldsValue({ bridgeCaptureMode: event.target.value });
+ }}
+ >
+
+ tc/eBPF telemetry capture
+ AF_PACKET on bridge member interfaces
+
+
+
+ )}
diff --git a/frontend/src/pages/Sniffing.tsx b/frontend/src/pages/Sniffing.tsx
index a9375c8..b7c4f14 100644
--- a/frontend/src/pages/Sniffing.tsx
+++ b/frontend/src/pages/Sniffing.tsx
@@ -23,7 +23,7 @@ export default function Sniffing(): ReactElement {
setStatusMap(status.interfaces ?? {});
} catch (error: any) {
console.error('fetchSnifferStatus error', error);
- message.error(error?.message ?? 'Failed to fetch sniffer status');
+ message.error(error?.message ?? 'Failed to fetch capture status');
} finally {
setStatusLoading(false);
}
diff --git a/frontend/src/types/sniffer.ts b/frontend/src/types/sniffer.ts
index 42b0d1e..1ab865f 100644
--- a/frontend/src/types/sniffer.ts
+++ b/frontend/src/types/sniffer.ts
@@ -5,6 +5,7 @@
export interface SnifferStartRequest {
bridge?: string;
interface?: string;
+ bridge_capture_mode?: 'tc_ebpf' | 'af_packet';
}
/**
@@ -15,6 +16,7 @@ export interface SnifferStartResponse {
session_id: string;
target: string;
target_type: 'bridge' | 'interface';
+ capture_mode: 'tc_ebpf' | 'af_packet';
}
/**
@@ -26,7 +28,7 @@ export interface SnifferStopRequest {
}
/**
- * Response returned when stopping sniffer(s).
+ * Response returned when stopping capture session(s).
*/
export interface SnifferStopResponse {
stopped: boolean;
@@ -36,7 +38,7 @@ export interface SnifferStopResponse {
}
/**
- * Per-interface sniffer status (now includes owning session info).
+ * Per-interface capture status (now includes owning session info).
*/
export interface InterfaceSnifferStatus {
running: boolean;
@@ -44,6 +46,7 @@ export interface InterfaceSnifferStatus {
up: boolean;
session_id?: string | null;
session_label?: string | null;
+ capture_mode?: 'tc_ebpf' | 'af_packet' | null;
}
/**