feat(docker): packet capture on kernel-datapath links via AF_PACKET

Replace the capture rejection with wiring to uBridge's capture module:
an AF_PACKET socket bound to the veth host end writes into the same pcap
file the relay path uses (capture start_kernel <if> "<pcap>" /
capture stop_kernel), so the pcap streaming to clients is unchanged.

- capture survives node restarts: _connect_nio re-arms it when the NIO
  is already capturing, mirroring the relay path
- uBridge's kernel capture is a singleton per process: a concurrent
  second capture on the same node surfaces uBridge's EALREADY error
- update the datapath docs: filters and markers remain relay-only,
  capture is now served on both datapaths
This commit is contained in:
YueGuobin 2026-09-28 00:46:29 +08:00
parent 58b97b4ce5
commit f6b869be63
No known key found for this signature in database
5 changed files with 90 additions and 15 deletions

View File

@ -1823,8 +1823,9 @@ class DockerVM(BaseNode):
whose host end will be enslaved to the per-link kernel bridge, and
whose guest end becomes the container interface (the role the TAP
plays on the relay path). No uBridge relay bridge is created — frames
flow entirely in the kernel, which is also why filters, markers and
capture are unavailable on this datapath.
flow entirely in the kernel. Filters and markers are unavailable on
this datapath (they live in the uBridge relay); packet capture is
provided by uBridge's AF_PACKET module on the veth host end.
"""
try:
@ -1942,6 +1943,10 @@ class DockerVM(BaseNode):
await self._ubridge_send(f'brctl show "{nio.bridge}"')
await self._ubridge_send(f'link set "{nio.bridge}" up')
await self._ubridge_send(f'brctl addif "{nio.bridge}" "{host_ifc}"')
if nio.capturing:
# Restore a capture that was active before a node restart
# (mirrors the relay path's start_capture in _connect_nio).
await self._ubridge_send(f'capture start_kernel {host_ifc} "{nio.pcap_output_file}"')
return
bridge_name = self._bridge_name(adapter_number, port_number)
@ -2132,9 +2137,16 @@ class DockerVM(BaseNode):
:param port_number: port number on the adapter (0 for single-port adapters)
"""
bridge_name = self._bridge_name(adapter_number, port_number)
if not self.ubridge:
raise DockerError("Cannot start the packet capture: uBridge is not running")
host_ifc = self._kernel_veths.get((adapter_number, port_number))
if host_ifc is not None:
# Kernel datapath: no relay bridge exists — capture via uBridge's
# AF_PACKET module bound to the veth host end. Single capture per
# uBridge process: a concurrent second one returns EALREADY.
await self._ubridge_send(f'capture start_kernel {host_ifc} "{output_file}"')
return
bridge_name = self._bridge_name(adapter_number, port_number)
await self._ubridge_send(f'bridge start_capture {bridge_name} "{output_file}"')
async def _stop_ubridge_capture(self, adapter_number, port_number=0):
@ -2145,9 +2157,13 @@ class DockerVM(BaseNode):
:param port_number: port number on the adapter (0 for single-port adapters)
"""
bridge_name = self._bridge_name(adapter_number, port_number)
if not self.ubridge:
raise DockerError("Cannot stop the packet capture: uBridge is not running")
if (adapter_number, port_number) in self._kernel_veths:
# Idempotent on the uBridge side (no active capture is OK).
await self._ubridge_send("capture stop_kernel")
return
bridge_name = self._bridge_name(adapter_number, port_number)
await self._ubridge_send(f"bridge stop_capture {bridge_name}")
async def start_capture(self, adapter_number, output_file, port_number=0):
@ -2162,11 +2178,6 @@ class DockerVM(BaseNode):
nio = self.get_nio(adapter_number, port_number)
if nio.capturing:
raise DockerError(f"Packet capture is already activated on adapter {adapter_number}")
if isinstance(nio, NIOBridge):
raise DockerError(
"Packet capture is not supported on kernel-datapath links (there is no uBridge "
"relay in the forwarding path); delete and recreate the link to capture it"
)
nio.start_packet_capture(output_file)
if self.status == "started" and self.ubridge:

View File

@ -116,7 +116,8 @@ class Link:
"""
Whether this link is wired on the kernel datapath (veth pairs
enslaved into a per-link Linux bridge — no uBridge relay in the
forwarding path, so filters, markers and capture are unavailable).
forwarding path, so filters and markers are unavailable; capture is
served by uBridge's AF_PACKET module).
"""
return any(d.get("type") == "nio_bridge" for d in (getattr(self, "_link_data", None) or []))

View File

@ -135,8 +135,9 @@ class UDPLink(Link):
"""
Whether this link can be wired on the kernel datapath (veth pairs
enslaved into a per-link Linux bridge) instead of the uBridge UDP
relay. The kernel path has no userspace relay, so filters, markers
and packet capture — which all live in the relay — disqualify it.
relay. The kernel path has no userspace relay, so filters and
markers — which live in the relay — disqualify it (capture is
served separately by uBridge's AF_PACKET module).
It is host-local (same compute) and requires the adapter interfaces
to be created as veths at container start, hence stopped nodes only.
"""

View File

@ -75,7 +75,8 @@ class BridgeNIO(BaseModel):
Kernel-datapath bridge Network Input/Output properties. The NIO tells the
node to enslave its veth host end into the named kernel bridge instead of
wiring a uBridge UDP relay. ``filters`` and ``markers`` must stay empty on
this datapath; they are accepted (and rejected server-side) only because
this datapath (rejected server-side); packet capture is supported via
uBridge's AF_PACKET module. Both are accepted as fields only because
link update payloads always carry the full NIO dictionary.
"""

View File

@ -339,15 +339,76 @@ async def test_update_nio_kernel_suspend_toggles_carrier(vm):
@pytest.mark.asyncio
async def test_start_capture_rejected_on_kernel_link(vm):
async def test_start_capture_kernel_uses_af_packet(vm):
vm._ubridge_hypervisor = MagicMock()
vm._ubridge_send = AsyncioMagicMock()
vm.status = "started"
host_ifc, _ = vm._veth_names(0, 0)
vm._kernel_veths[(0, 0)] = host_ifc
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
with pytest.raises(DockerError, match="kernel-datapath"):
await vm.start_capture(0, "/tmp/capture.pcap")
assert nio.capturing is True
vm._ubridge_send.assert_any_call(f'capture start_kernel {host_ifc} "/tmp/capture.pcap"')
@pytest.mark.asyncio
async def test_stop_capture_kernel(vm):
vm._ubridge_hypervisor = MagicMock()
vm._ubridge_send = AsyncioMagicMock()
vm.status = "started"
host_ifc, _ = vm._veth_names(0, 0)
vm._kernel_veths[(0, 0)] = host_ifc
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
await vm.start_capture(0, "/tmp/capture.pcap")
await vm.stop_capture(0)
assert nio.capturing is False
vm._ubridge_send.assert_any_call("capture stop_kernel")
@pytest.mark.asyncio
async def test_start_capture_second_concurrent_fails(vm):
"""
uBridge's kernel capture is a singleton per process: a second concurrent
capture returns EALREADY and must surface to the caller.
"""
async def send(command):
if "capture start_kernel" in command:
raise UbridgeError("Could not start kernel capture: Operation already in progress")
vm._ubridge_hypervisor = MagicMock()
vm._ubridge_send = AsyncioMagicMock(side_effect=send)
vm.status = "started"
vm._kernel_veths[(0, 0)] = vm._veth_names(0, 0)[0]
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
with pytest.raises(UbridgeError, match="already in progress"):
await vm.start_capture(0, "/tmp/capture.pcap")
@pytest.mark.asyncio
async def test_connect_nio_kernel_restarts_capture(vm):
vm._ubridge_send = AsyncioMagicMock()
host_ifc, _ = vm._veth_names(0, 0)
vm._kernel_veths[(0, 0)] = host_ifc
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
nio.start_packet_capture("/tmp/capture.pcap")
await vm._connect_nio(0, nio)
vm._ubridge_send.assert_any_call(f'capture start_kernel {host_ifc} "/tmp/capture.pcap"')
@pytest.mark.asyncio
async def test_remove_kernel_veths(vm):