From f6b869be6338193831779f5cf597339731608626 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 28 Sep 2026 00:46:29 +0800 Subject: [PATCH] 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 "" / 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 --- gns3server/compute/docker/docker_vm.py | 29 ++++++--- gns3server/controller/link.py | 3 +- gns3server/controller/udp_link.py | 5 +- gns3server/schemas/compute/nios.py | 3 +- .../docker/test_docker_kernel_datapath.py | 65 ++++++++++++++++++- 5 files changed, 90 insertions(+), 15 deletions(-) diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index c9dcf6cc5..a3f98f96f 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -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: diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 1091192b8..a919c8037 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -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 [])) diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index d78bf6c17..27b2923a1 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -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. """ diff --git a/gns3server/schemas/compute/nios.py b/gns3server/schemas/compute/nios.py index 10f8ab565..4ae5a6475 100644 --- a/gns3server/schemas/compute/nios.py +++ b/gns3server/schemas/compute/nios.py @@ -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. """ diff --git a/tests/compute/docker/test_docker_kernel_datapath.py b/tests/compute/docker/test_docker_kernel_datapath.py index bc86e5fb5..e9437861c 100644 --- a/tests/compute/docker/test_docker_kernel_datapath.py +++ b/tests/compute/docker/test_docker_kernel_datapath.py @@ -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):