test af_packet mode on bridges
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 10s

This commit is contained in:
2026-04-12 17:58:19 +02:00
parent 9ee47284e0
commit 4521b9d99e
5 changed files with 131 additions and 36 deletions

View File

@@ -6,6 +6,8 @@ from fastapi import APIRouter, Body, HTTPException, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from src.network_sniffer import ( from src.network_sniffer import (
BRIDGE_CAPTURE_MODE_AF_PACKET,
BRIDGE_CAPTURE_MODE_TC_EBPF,
get_internal_debug_state, get_internal_debug_state,
get_capture_session_status, get_capture_session_status,
start_capture_session, start_capture_session,
@@ -28,6 +30,11 @@ class SnifferStartRequest(BaseModel):
example="eth0", example="eth0",
description="Interface name to sniff.", 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): class SnifferStartResponse(BaseModel):
@@ -37,6 +44,7 @@ class SnifferStartResponse(BaseModel):
session_id: str = Field(..., description="Unique session identifier.") session_id: str = Field(..., description="Unique session identifier.")
target: str = Field(..., description="Started target name.") target: str = Field(..., description="Started target name.")
target_type: str = Field(..., description="Either 'bridge' or 'interface'.") 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): class SnifferStopRequest(BaseModel):
@@ -62,6 +70,7 @@ class InterfaceSnifferStatus(BaseModel):
up: bool = Field(..., description="Whether the interface is operationally up.") up: bool = Field(..., description="Whether the interface is operationally up.")
session_id: Optional[str] = Field(None, description="Owning capture session ID.") session_id: Optional[str] = Field(None, description="Owning capture session ID.")
session_label: Optional[str] = Field(None, description="Human-readable session label.") 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): class SnifferStatusResponse(BaseModel):
@@ -87,14 +96,23 @@ def sniffer_start(req: SnifferStartRequest) -> SnifferStartResponse:
session_id=session_id, session_id=session_id,
target=req.interface, target=req.interface,
target_type="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( return SnifferStartResponse(
started=True, started=True,
session_id=session_id, session_id=session_id,
target=req.bridge, target=req.bridge,
target_type="bridge", target_type="bridge",
capture_mode=effective_capture_mode,
) )
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to start sniffer: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to start sniffer: {exc}") from exc

View File

@@ -56,6 +56,9 @@ sessions: Dict[str, Dict[str, Any]] = {}
# "ports": List[str], # "ports": List[str],
# } # }
BRIDGE_CAPTURE_MODE_TC_EBPF = "tc_ebpf"
BRIDGE_CAPTURE_MODE_AF_PACKET = "af_packet"
# ------------------------- # -------------------------
# PacketInfo typing # PacketInfo typing
# ------------------------- # -------------------------
@@ -545,7 +548,7 @@ def _sync_bridge_telemetry() -> None:
bridge_session_interfaces = { bridge_session_interfaces = {
session_id: list(session.get("ports", [])) session_id: list(session.get("ports", []))
for session_id, session in sessions.items() 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: try:
bridge_telemetry_manager.update_sessions(bridge_session_interfaces) bridge_telemetry_manager.update_sessions(bridge_session_interfaces)
@@ -558,7 +561,11 @@ def _sync_bridge_telemetry() -> None:
for session in sessions.values() for session in sessions.values()
for iface in ( for iface in (
list(session.get("capture_ifaces", [])) 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 if iface
} }
@@ -677,20 +684,31 @@ def _session_reader_loop(session_id: str) -> None:
# ------------------------- # -------------------------
# Public API: start/stop/status # 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. 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 == 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 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_id = str(uuid4())
session: Dict[str, Any] = { session: Dict[str, Any] = {
"stop_event": threading.Event(), "stop_event": threading.Event(),
"sockets": {}, "sockets": {},
"label": target, "label": target,
"is_bridge": not target_is_interface, "is_bridge": not target_is_interface,
"capture_mode": effective_capture_mode,
"ports": [], "ports": [],
"capture_ifaces": [], "capture_ifaces": [],
} }
@@ -701,7 +719,7 @@ def start_capture_session(target: str, target_is_interface: bool = False) -> str
capture_ifaces = [target] capture_ifaces = [target]
else: else:
ports = get_bridge_ports_once(target) 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["ports"] = ports
session["capture_ifaces"] = capture_ifaces session["capture_ifaces"] = capture_ifaces
@@ -729,9 +747,10 @@ def start_capture_session(target: str, target_is_interface: bool = False) -> str
session["thread"] = None session["thread"] = None
_sync_bridge_telemetry() _sync_bridge_telemetry()
logger.info( 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, session_id,
target, target,
effective_capture_mode,
ports, ports,
capture_ifaces, capture_ifaces,
) )
@@ -839,6 +858,7 @@ def get_capture_session_status() -> Dict[str, Dict[str, object]]:
"up": check_interface_up(iface), "up": check_interface_up(iface),
"session_id": sid, "session_id": sid,
"session_label": s.get("label"), "session_label": s.get("label"),
"capture_mode": s.get("capture_mode"),
} }
if not s.get("sockets") and s.get("is_bridge"): if not s.get("sockets") and s.get("is_bridge"):
for iface in s.get("ports", []): 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), "up": check_interface_up(iface),
"session_id": sid, "session_id": sid,
"session_label": s.get("label"), "session_label": s.get("label"),
"capture_mode": s.get("capture_mode"),
} }
return out return out
@@ -861,6 +882,7 @@ def get_internal_debug_state() -> dict:
sid: { sid: {
"label": s.get("label"), "label": s.get("label"),
"is_bridge": s.get("is_bridge"), "is_bridge": s.get("is_bridge"),
"capture_mode": s.get("capture_mode"),
"ports": list(s.get("ports", [])), "ports": list(s.get("ports", [])),
"capture_ifaces": list(s.get("capture_ifaces", [])), "capture_ifaces": list(s.get("capture_ifaces", [])),
"sockets": list(s.get("sockets", {}).keys()), "sockets": list(s.get("sockets", {}).keys()),
@@ -870,16 +892,35 @@ def get_internal_debug_state() -> dict:
for sid, s in sessions.items() for sid, s in sessions.items()
}, },
"buffer_len": len(_PACKET_BUFFER), "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", "bridge_capture_mode": (
"telemetry_ports": sorted({iface for session in sessions.values() for iface in session.get("ports", [])}), "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(), "tshark": tshark_manager.get_debug_snapshot(),
"packet_tracker": packet_tracker.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().""" """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( def stop_afpacket_sniffer(

View File

@@ -43,6 +43,7 @@ interface SnifferManagerProps {
export default function SnifferManager(props: SnifferManagerProps): ReactElement { export default function SnifferManager(props: SnifferManagerProps): ReactElement {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [startMode, setStartMode] = useState<'interface' | 'bridge'>('interface'); const [startMode, setStartMode] = useState<'interface' | 'bridge'>('interface');
const [bridgeCaptureMode, setBridgeCaptureMode] = useState<'tc_ebpf' | 'af_packet'>('tc_ebpf');
const [form] = Form.useForm(); const [form] = Form.useForm();
const statusEntries = useMemo( const statusEntries = useMemo(
@@ -53,6 +54,7 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
const onOpenStartModal = () => { const onOpenStartModal = () => {
form.resetFields(); form.resetFields();
setStartMode('interface'); setStartMode('interface');
setBridgeCaptureMode('tc_ebpf');
setIsModalOpen(true); setIsModalOpen(true);
}; };
@@ -60,27 +62,30 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
setIsModalOpen(false); setIsModalOpen(false);
}; };
const handleStartSubmit = async (values: { target?: string }) => { const handleStartSubmit = async (values: { target?: string; bridgeCaptureMode?: 'tc_ebpf' | 'af_packet' }) => {
const target = values.target; const target = values.target;
if (!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; return;
} }
try { 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); const result = await startSniffer(payload);
notification.success({ notification.success({
message: 'Sniffer started', message: 'Capture started',
description: `Sniffer started on ${target} (session ${result.session_id})`, description: `Capture started on ${target} via ${result.capture_mode} (session ${result.session_id})`,
}); });
await props.refreshAll(); await props.refreshAll();
setIsModalOpen(false); setIsModalOpen(false);
} catch (error: any) { } catch (error: any) {
console.error('startSniffer error', error); console.error('startSniffer error', error);
notification.error({ notification.error({
message: 'Failed to start sniffer', message: 'Failed to start capture',
description: error?.message ?? 'Failed to start sniffer', description: error?.message ?? 'Failed to start capture',
}); });
} }
}; };
@@ -88,13 +93,13 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
const handleStopAll = async () => { const handleStopAll = async () => {
try { try {
await stopSniffer(); await stopSniffer();
notification.success({ message: 'All sniffers stopped' }); notification.success({ message: 'All capture sessions stopped' });
await props.refreshStatus(); await props.refreshStatus();
} catch (error: any) { } catch (error: any) {
console.error('stopSniffer error', error); console.error('stopSniffer error', error);
notification.error({ notification.error({
message: 'Failed to stop sniffers', message: 'Failed to stop capture sessions',
description: error?.message ?? 'Failed to stop sniffers', description: error?.message ?? 'Failed to stop capture sessions',
}); });
} }
}; };
@@ -103,8 +108,8 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
try { try {
await stopSnifferByInterface(ifaceName); await stopSnifferByInterface(ifaceName);
notification.success({ notification.success({
message: 'Sniffer stopped', message: 'Capture stopped',
description: `Sniffer stopped on interface ${ifaceName}`, description: `Capture stopped on interface ${ifaceName}`,
}); });
await props.refreshStatus(); await props.refreshStatus();
return; return;
@@ -116,8 +121,8 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
try { try {
await stopSniffer({ session_id: sessionId }); await stopSniffer({ session_id: sessionId });
notification.success({ notification.success({
message: 'Sniffer stopped', message: 'Capture stopped',
description: `Sniffer stopped on interface ${ifaceName} (session ${sessionId})`, description: `Capture stopped on interface ${ifaceName} (session ${sessionId})`,
}); });
await props.refreshStatus(); await props.refreshStatus();
return; return;
@@ -127,8 +132,8 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
} }
notification.error({ notification.error({
message: 'Failed to stop sniffer', message: 'Failed to stop capture',
description: 'Could not stop sniffer for this interface.', description: 'Could not stop capture for this interface.',
}); });
}; };
@@ -145,7 +150,7 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
<Tooltip title="Refresh status"> <Tooltip title="Refresh status">
<Button icon={<ReloadOutlined />} onClick={() => props.refreshStatus()} /> <Button icon={<ReloadOutlined />} onClick={() => props.refreshStatus()} />
</Tooltip> </Tooltip>
<Tooltip title="Stop all sniffers"> <Tooltip title="Stop all capture sessions">
<Button danger icon={<StopOutlined />} onClick={handleStopAll} /> <Button danger icon={<StopOutlined />} onClick={handleStopAll} />
</Tooltip> </Tooltip>
</Space> </Space>
@@ -189,15 +194,15 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
try { try {
const result = await startSniffer({ interface: name }); const result = await startSniffer({ interface: name });
notification.success({ notification.success({
message: 'Sniffer started', message: 'Capture started',
description: `Sniffer started on ${name} (session ${result.session_id})`, description: `Capture started on ${name} via ${result.capture_mode} (session ${result.session_id})`,
}); });
await props.refreshStatus(); await props.refreshStatus();
} catch (error: any) { } catch (error: any) {
console.error('startSniffer quick', error); console.error('startSniffer quick', error);
notification.error({ notification.error({
message: 'Failed to start sniffer', message: 'Failed to start capture',
description: error?.message ?? 'Failed to start sniffer', description: error?.message ?? 'Failed to start capture',
}); });
} }
}} }}
@@ -231,6 +236,11 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
</Text> </Text>
</Tag> </Tag>
)} )}
{status.capture_mode && (
<Tag color={status.capture_mode === 'af_packet' ? 'geekblue' : 'purple'}>
{status.capture_mode === 'af_packet' ? 'AF_PACKET' : 'tc/eBPF'}
</Tag>
)}
</Space> </Space>
} }
description={<Text type="secondary">interface: {name}</Text>} description={<Text type="secondary">interface: {name}</Text>}
@@ -256,7 +266,8 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
value={startMode} value={startMode}
onChange={(event) => { onChange={(event) => {
setStartMode(event.target.value); setStartMode(event.target.value);
form.setFieldsValue({ target: undefined }); setBridgeCaptureMode('tc_ebpf');
form.setFieldsValue({ target: undefined, bridgeCaptureMode: 'tc_ebpf' });
}} }}
> >
<Radio value="interface">Interface</Radio> <Radio value="interface">Interface</Radio>
@@ -290,6 +301,28 @@ export default function SnifferManager(props: SnifferManagerProps): ReactElement
))} ))}
</Select> </Select>
</Form.Item> </Form.Item>
{startMode === 'bridge' && (
<Form.Item
label="Bridge Capture Path"
name="bridgeCaptureMode"
initialValue="tc_ebpf"
extra="Choose between tc/eBPF bridge telemetry or direct AF_PACKET capture on the bridge member interfaces."
>
<Radio.Group
value={bridgeCaptureMode}
onChange={(event) => {
setBridgeCaptureMode(event.target.value);
form.setFieldsValue({ bridgeCaptureMode: event.target.value });
}}
>
<Space direction="vertical">
<Radio value="tc_ebpf">tc/eBPF telemetry capture</Radio>
<Radio value="af_packet">AF_PACKET on bridge member interfaces</Radio>
</Space>
</Radio.Group>
</Form.Item>
)}
</Form> </Form>
</Modal> </Modal>
</div> </div>

View File

@@ -23,7 +23,7 @@ export default function Sniffing(): ReactElement {
setStatusMap(status.interfaces ?? {}); setStatusMap(status.interfaces ?? {});
} catch (error: any) { } catch (error: any) {
console.error('fetchSnifferStatus error', error); console.error('fetchSnifferStatus error', error);
message.error(error?.message ?? 'Failed to fetch sniffer status'); message.error(error?.message ?? 'Failed to fetch capture status');
} finally { } finally {
setStatusLoading(false); setStatusLoading(false);
} }

View File

@@ -5,6 +5,7 @@
export interface SnifferStartRequest { export interface SnifferStartRequest {
bridge?: string; bridge?: string;
interface?: string; interface?: string;
bridge_capture_mode?: 'tc_ebpf' | 'af_packet';
} }
/** /**
@@ -15,6 +16,7 @@ export interface SnifferStartResponse {
session_id: string; session_id: string;
target: string; target: string;
target_type: 'bridge' | 'interface'; 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 { export interface SnifferStopResponse {
stopped: boolean; 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 { export interface InterfaceSnifferStatus {
running: boolean; running: boolean;
@@ -44,6 +46,7 @@ export interface InterfaceSnifferStatus {
up: boolean; up: boolean;
session_id?: string | null; session_id?: string | null;
session_label?: string | null; session_label?: string | null;
capture_mode?: 'tc_ebpf' | 'af_packet' | null;
} }
/** /**