diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index cf48f4ab7..25948a344 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -1896,16 +1896,9 @@ class DockerVM(DockerKernelDatapathMixin, BaseNode): if self.status == "started": host_ifc = self._kernel_veths.get((adapter_number, port_number)) if host_ifc is not None: - # Incremental marker reconcile on the veth anchor — - # the relay-datapath equivalent of the branch below. - await self._ubridge_apply_markers(host_ifc, nio) - # The netem apply resets the interface first (the - # kernel merges optional netem attrs on replace), so - # everything anchored on clsact must re-apply after - # it: bpf drops flush + re-add, eBPF modes re-set. - await self._ubridge_apply_netem(host_ifc, nio.filters) - await self._ubridge_apply_bpf_drops(host_ifc, nio.filters) - await self._ubridge_apply_ebpf_drops(host_ifc, nio.filters) + # Incremental marker + filter reconcile on the anchor + # — the relay-datapath equivalent of the branch below. + await self._kernel_update(host_ifc, nio) await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number) return diff --git a/gns3server/compute/kernel_datapath.py b/gns3server/compute/kernel_datapath.py index 1567b61fa..a993ac1fa 100644 --- a/gns3server/compute/kernel_datapath.py +++ b/gns3server/compute/kernel_datapath.py @@ -171,6 +171,64 @@ class KernelDatapathMixin: await self._ubridge_apply_bpf_drops(anchor, nio.filters) await self._ubridge_apply_ebpf_drops(anchor, nio.filters) + async def _relay_attach(self, anchor, bridge_name, nio): + """ + Attach an anchor to a uBridge relay bridge (NIOUDP): the bridge turns + the anchor into an AF_PACKET endpoint and relays it to the link's UDP + peer. Called on link creation and on node start, and after a link was + re-created — the relay analogue of _kernel_attach. + """ + + await self._ubridge_send(f"bridge create {bridge_name}") + # libpcap cannot open a packet socket on an admin-down interface (its + # netlink promiscuous-mode transaction returns ENOENT), and an anchor + # is born down (carrier off until a link attaches) — bring it up for + # the relay attach. The carrier pass in the caller refines the state + # afterwards (a suspended NIO sets it back down). + await self._ubridge_send(f'link set "{anchor}" up') + await self._ubridge_send(f'bridge add_nio_ethernet {bridge_name} "{anchor}"') + await self._ubridge_send( + "bridge add_nio_udp {bridge_name} {lport} {rhost} {rport}".format( + bridge_name=bridge_name, lport=nio.lport, rhost=nio.rhost, rport=nio.rport + ) + ) + if nio.capturing: + await self._ubridge_send( + 'bridge start_capture {bridge_name} "{pcap_file}"'.format( + bridge_name=bridge_name, pcap_file=nio.pcap_output_file + ) + ) + await self._ubridge_send(f"bridge start {bridge_name}") + await self._ubridge_apply_filters(bridge_name, nio.filters) + await self._ubridge_apply_markers(bridge_name, nio) + + async def _relay_detach(self, bridge_name): + """ + Drop a relay bridge: its AF_PACKET endpoint on the anchor goes with + it, which is what makes switching an adapter from the relay datapath + to a kernel link safe (otherwise the anchor would both be a bridge + port and a relay endpoint, duplicating one direction). + """ + + if self.ubridge: + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f"bridge delete {bridge_name}") + + async def _kernel_update(self, anchor, nio): + """ + Re-apply everything a kernel link carries on an already-attached + anchor (filters and markers changed): no re-enslaving, no bridge + create. + """ + + await self._ubridge_apply_markers(anchor, nio) + # The netem apply resets the interface first (the kernel merges + # optional netem attrs on replace), so everything anchored on clsact + # must re-apply after it: bpf drops flush + re-add, eBPF modes re-set. + await self._ubridge_apply_netem(anchor, nio.filters) + await self._ubridge_apply_bpf_drops(anchor, nio.filters) + await self._ubridge_apply_ebpf_drops(anchor, nio.filters) + async def _remove_kernel_nio(self, nio, adapter_number, port_number=0): """ Detach an anchor from its per-link kernel bridge. Both endpoints run diff --git a/gns3server/compute/qemu/qemu_vm.py b/gns3server/compute/qemu/qemu_vm.py index 48e25ab7c..e8c73774e 100644 --- a/gns3server/compute/qemu/qemu_vm.py +++ b/gns3server/compute/qemu/qemu_vm.py @@ -48,6 +48,8 @@ from ...utils.images import md5sum from ..adapters.ethernet_adapter import EthernetAdapter from ..base_node import BaseNode from ..error import ImageMissingError, NodeError +from ..kernel_datapath import KernelDatapathMixin +from ..nios.nio_bridge import NIOBridge from ..nios.nio_tap import NIOTAP from ..nios.nio_udp import NIOUDP from .qemu_error import QemuError @@ -61,7 +63,7 @@ FORBIDDEN_OPTIONS = {"-blockdev", "-drive", "-hda", "-hdb", "-hdc", "-hdd", "-fs FORBIDDEN_OPTIONS |= {"-" + opt for opt in FORBIDDEN_OPTIONS if opt.startswith("-") and not opt.startswith("--")} -class QemuVM(BaseNode): +class QemuVM(KernelDatapathMixin, BaseNode): module_name = "qemu" """ @@ -118,7 +120,12 @@ class QemuVM(BaseNode): self._stdout_file = "" self._qemu_img_stdout_file = "" self._execute_lock = asyncio.Lock() - self._local_udp_tunnels = {} + self._local_udp_tunnels = {} # legacy datapath only (see _tap_datapath) + self._kernel_taps = {} + self._ubridge_tc_caps = None + # Whether this VM runs on the TAP datapath (uBridge created the + # adapter TAPs) or on the legacy socket-netdev one. Decided at start. + self._tap_datapath = False self._guest_cid = None self._command_line_changed = False self._qemu_version = None @@ -1118,6 +1125,14 @@ class QemuVM(BaseNode): if self._tpm: await self._start_swtpm() + # uBridge owns the adapter TAPs and must be up before they are + # created — and before QEMU opens them. Unlike a container, whose + # veths are created after it starts, the TAP has to pre-exist the + # process that reads it. + self._ubridge_tc_caps = None + await self._start_ubridge() + await self._prepare_tap_datapath() + command = await self._build_command() command_string = " ".join(shlex.quote(s) for s in command) try: @@ -1137,6 +1152,9 @@ class QemuVM(BaseNode): except (OSError, subprocess.SubprocessError, UnicodeEncodeError) as e: stdout = self.read_stdout() log.error(f"Could not start QEMU {self.qemu_path}: {e}\n{stdout}") + # uBridge was started (and the adapter TAPs created) before + # the launch: don't leave either behind on a failed start. + await self._stop_ubridge() raise QemuError(f"Could not start QEMU {self.qemu_path}: {e}\n{stdout}") await self._set_process_priority() @@ -1145,14 +1163,12 @@ class QemuVM(BaseNode): if "-enable-kvm" in command_string or "-enable-hax" in command_string: self._hw_virtualization = True - await self._start_ubridge() set_link_commands = [] for adapter_number, adapter in enumerate(self._ethernet_adapters): nio = adapter.get_nio(0) if nio: - await self.add_ubridge_udp_connection( - f"QEMU-{self._id}-{adapter_number}", self._local_udp_tunnels[adapter_number][1], nio - ) + await self._connect_nio(adapter_number, nio) + await self._set_adapter_carrier(adapter_number, not nio.suspend) if nio.suspend and self._replicate_network_connection_state: set_link_commands.append(f"set_link gns3-{adapter_number} off") elif self._replicate_network_connection_state: @@ -1472,6 +1488,156 @@ class QemuVM(BaseNode): else: log.debug(f"QEMU VM is not paused to be resumed, current status is {vm_status}") + # ------------------------------------------------------------------ + # Kernel datapath: the adapter anchor is a persistent TAP + # ------------------------------------------------------------------ + + def _tap_name(self, adapter_number, port_number=0): + """ + Deterministic anchor TAP name for an adapter port. The ``gq`` prefix + keeps it out of the ``gns3`` bridge/TAP name space (and apart from + Docker's ``gv``/``gc`` veth names); 8 hex chars of the node id plus + adapter/port keep it unique and within IFNAMSIZ (15). + """ + + return f"gq{self._id.replace('-', '')[:8]}e{adapter_number}p{port_number}" + + def _kernel_host_ifc(self, adapter_number, port_number=0): + """ + The persistent TAP this adapter owns, or None before the VM started + (or on the legacy socket-netdev datapath). + """ + + return self._kernel_taps.get((adapter_number, port_number)) + + def _kernel_anchors(self): + return set(self._kernel_taps.values()) + + def _kernel_error(self, message): + return QemuError(message) + + async def _prepare_tap_datapath(self): + """ + Probe uBridge's tap module and, when it answers, create the TAP every + adapter owns — before QEMU is launched, so the netdev is already + there. A build without the module (or without CAP_NET_ADMIN) keeps the + legacy socket-netdev datapath: the local UDP tunnels to a uBridge + relay this node used before the TAP work. Kernel links require the TAP + datapath. + """ + + self._tap_datapath = False + self._kernel_taps.clear() + if not self.ubridge: + return + + probe = f"gq{self._id.replace('-', '')[:8]}prob" + try: + await self._ubridge_send(f'tap create "{probe}"') + except UbridgeError as e: + log.warning("QEMU VM '%s': uBridge cannot create persistent TAPs (%s)", self._name, e) + self.project.emit( + "log.warning", + { + "message": f"QEMU VM '{self._name}': uBridge does not support persistent TAPs ({e}); " + "this node runs on the legacy relay datapath and cannot carry kernel links" + }, + ) + return + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'tap delete "{probe}"') + + self._tap_datapath = True + await self._create_taps() + + async def _create_taps(self): + """ + Create the persistent TAP every adapter owns. Each adapter is born as + a TAP whatever its NIO type — the anchor role Docker's veth host end + plays — so a link attaches to (or detaches from) a running VM without + touching its interfaces. uBridge creates the device and hands + ownership to this user, so the unprivileged QEMU process can open it; + the TAP starts DOWN (carrier off until a link attaches). A persistent + TAP outlives its creator, so a leftover from a previous run (crash, + kill) is swept first. + """ + + for adapter_number, adapter in enumerate(self._ethernet_adapters): + tap = self._tap_name(adapter_number) + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'tap delete "{tap}"') + await self._ubridge_send(f'tap create "{tap}"') + try: + await self._ubridge_send(f"tap set_owner {tap} {os.getuid()}") + except UbridgeError as e: + # Only root could open the TAP now: the VM would fail to + # attach its netdev, which is worth surfacing here rather + # than as a launch failure. + log.warning("QEMU VM '%s': could not hand TAP %s to uid %s: %s", self._name, tap, os.getuid(), e) + await self._ubridge_send(f'link set "{tap}" down') + self._kernel_taps[(adapter_number, 0)] = tap + + async def _remove_taps(self): + """ + Delete the adapter TAPs together with the per-link kernel bridges + this VM still holds: a stopped VM has no links attached, so those + bridges are orphans whose ports just disappeared. + """ + + if self.ubridge: + for tap in self._kernel_taps.values(): + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'tap delete "{tap}"') + await self._remove_kernel_bridges() + self._kernel_taps.clear() + + async def _stop_ubridge(self): + """ + Stops uBridge, removing the adapter TAPs first: they live in the root + namespace and outlive it (the tap module needs the control channel to + delete them), and QEMU holding the other end keeps each device alive + until it exits — unpersisted here, they disappear with the process. + The next start spawns a fresh uBridge, so the tc capabilities must be + probed again. + """ + + if self.ubridge: + await self._remove_taps() + self._ubridge_tc_caps = None + await super()._stop_ubridge() + + async def _connect_nio(self, adapter_number, nio, port_number=0): + """ + Attach a link's NIO to the adapter's TAP anchor: a kernel link + enslaves the TAP into the per-link kernel bridge, a relay link + attaches it to the uBridge relay bridge as an AF_PACKET endpoint. + """ + + bridge_name = f"QEMU-{self._id}-{adapter_number}" + if not self._tap_datapath: + if isinstance(nio, NIOBridge): + raise QemuError( + "Adapter {adapter_number} of QEMU VM '{name}' runs on the legacy relay datapath " + "(this uBridge cannot create persistent TAPs) and cannot carry a kernel link".format( + adapter_number=adapter_number, name=self._name + ) + ) + await self.add_ubridge_udp_connection(bridge_name, self._local_udp_tunnels[adapter_number][1], nio) + return + + anchor = self._kernel_host_ifc(adapter_number, port_number) + if anchor is None: + raise QemuError( + "Adapter {adapter_number} port {port_number} of QEMU VM '{name}' has no TAP interface; " + "restart the node to attach a link to it".format( + adapter_number=adapter_number, port_number=port_number, name=self._name + ) + ) + if isinstance(nio, NIOBridge): + await self._kernel_attach(anchor, nio) + return + await self._relay_attach(anchor, bridge_name, nio) + async def adapter_add_nio_binding(self, adapter_number, nio): """ Adds an adapter NIO binding. @@ -1487,13 +1653,16 @@ class QemuVM(BaseNode): if self.is_running(): try: - await self.add_ubridge_udp_connection( - f"QEMU-{self._id}-{adapter_number}", self._local_udp_tunnels[adapter_number][1], nio - ) - if self._replicate_network_connection_state: - await self._control_vm(f"set_link gns3-{adapter_number} on") + await self._connect_nio(adapter_number, nio) except (IndexError, KeyError): - raise QemuError(f'Adapter {adapter_number} does not exist on QEMU VM "{self._name}"') + raise QemuError( + 'Adapter {adapter_number} does not exist on QEMU VM "{name}"'.format( + name=self._name, adapter_number=adapter_number + ) + ) + await self._set_adapter_carrier(adapter_number, not nio.suspend) + if self._replicate_network_connection_state: + await self._control_vm(f"set_link gns3-{adapter_number} on") adapter.add_nio(0, nio) log.debug(f'QEMU VM "{self._name}" [{self._id}]: {nio} added to adapter {adapter_number}') @@ -1508,9 +1677,21 @@ class QemuVM(BaseNode): if self.is_running(): try: - await self.update_ubridge_udp_connection( - f"QEMU-{self._id}-{adapter_number}", self._local_udp_tunnels[adapter_number][1], nio - ) + if self._tap_datapath and isinstance(nio, NIOBridge): + # Filters or markers changed on an attached kernel link: + # re-apply on the anchor, no re-enslaving. + anchor = self._kernel_host_ifc(adapter_number) + if anchor is not None: + await self._kernel_update(anchor, nio) + elif self._tap_datapath: + # Relay link on the TAP anchor: re-apply on its bridge. + await self._ubridge_apply_filters(f"QEMU-{self._id}-{adapter_number}", nio.filters) + await self._ubridge_apply_markers(f"QEMU-{self._id}-{adapter_number}", nio) + else: + await self.update_ubridge_udp_connection( + f"QEMU-{self._id}-{adapter_number}", self._local_udp_tunnels[adapter_number][1], nio + ) + await self._set_adapter_carrier(adapter_number, not nio.suspend) if self._replicate_network_connection_state: if nio.suspend: await self._control_vm(f"set_link gns3-{adapter_number} off") @@ -1533,13 +1714,15 @@ class QemuVM(BaseNode): except IndexError: raise QemuError(f'Adapter {adapter_number} does not exist on QEMU VM "{self._name}"') + nio = adapter.get_nio(0) await self.stop_capture(adapter_number) if self.is_running(): if self._replicate_network_connection_state: await self._control_vm(f"set_link gns3-{adapter_number} off") - await self._ubridge_send("bridge delete {name}".format(name=f"QEMU-{self._id}-{adapter_number}")) - - nio = adapter.get_nio(0) + if isinstance(nio, NIOBridge): + await self._remove_kernel_nio(nio, adapter_number) + else: + await self._relay_detach(f"QEMU-{self._id}-{adapter_number}") if isinstance(nio, NIOUDP): self.manager.port_manager.release_udp_port(nio.lport, self._project) adapter.remove_nio(0) @@ -1582,11 +1765,16 @@ class QemuVM(BaseNode): nio.start_packet_capture(output_file) if self.ubridge: - await self._ubridge_send( - 'bridge start_capture {name} "{output_file}"'.format( - name=f"QEMU-{self._id}-{adapter_number}", output_file=output_file + anchor = self._kernel_host_ifc(adapter_number) + if isinstance(nio, NIOBridge) and anchor is not None: + # Kernel link: capture on the adapter's TAP anchor (AF_PACKET). + await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"') + else: + await self._ubridge_send( + 'bridge start_capture {name} "{output_file}"'.format( + name=f"QEMU-{self._id}-{adapter_number}", output_file=output_file + ) ) - ) log.debug(f"QEMU VM '{self.name}' [{self.id}]: starting packet capture on adapter {adapter_number}") @@ -1603,7 +1791,10 @@ class QemuVM(BaseNode): nio.stop_packet_capture() if self.ubridge: - await self._ubridge_send("bridge stop_capture {name}".format(name=f"QEMU-{self._id}-{adapter_number}")) + if isinstance(nio, NIOBridge) and self._kernel_host_ifc(adapter_number) is not None: + await self._ubridge_send("capture stop_kernel") + else: + await self._ubridge_send("bridge stop_capture {name}".format(name=f"QEMU-{self._id}-{adapter_number}")) log.debug(f"QEMU VM '{self.name}' [{self.id}]: stopping packet capture on adapter {adapter_number}") @@ -2387,10 +2578,7 @@ class QemuVM(BaseNode): for adapter_number, adapter in enumerate(self._ethernet_adapters): mac = int_to_macaddress(macaddress_to_int(self._mac_address) + adapter_number) - # use a local UDP tunnel to connect to uBridge instead - if adapter_number not in self._local_udp_tunnels: - self._local_udp_tunnels[adapter_number] = self._create_local_udp_tunnel() - nio = self._local_udp_tunnels[adapter_number][0] + nio = adapter.get_nio(0) custom_adapter = self._get_custom_adapter_settings(adapter_number) adapter_type = custom_adapter.get("adapter_type", self._adapter_type) @@ -2415,23 +2603,33 @@ class QemuVM(BaseNode): addr = pci_device_id % 32 device_string = f"{device_string},bus=pci-bridge{bridge_id},addr=0x{addr:02x}" pci_device_id += 1 - if nio: - network_options.extend(["-device", f"{device_string},netdev=gns3-{adapter_number}"]) - if isinstance(nio, NIOUDP): - network_options.extend( - [ - "-netdev", - "socket,id=gns3-{},udp={}:{},localaddr={}:{}".format( - adapter_number, nio.rhost, nio.rport, "127.0.0.1", nio.lport - ), - ] - ) - elif isinstance(nio, NIOTAP): - network_options.extend( - ["-netdev", f"tap,id=gns3-{adapter_number},ifname={nio.tap_device},script=no,downscript=no"] - ) + # Every adapter is backed by its own TAP (created before launch, + # see _create_taps): the netdev is a kernel-side interface the + # link attaches to, not a UDP socket into uBridge. A legacy + # NIOTAP NIO still names its own device. + if isinstance(nio, NIOTAP): + tap = nio.tap_device else: - network_options.extend(["-device", device_string]) + tap = self._kernel_host_ifc(adapter_number) or self._tap_name(adapter_number) + network_options.extend(["-device", f"{device_string},netdev=gns3-{adapter_number}"]) + if self._tap_datapath or isinstance(nio, NIOTAP): + network_options.extend( + ["-netdev", f"tap,id=gns3-{adapter_number},ifname={tap},script=no,downscript=no"] + ) + else: + # Legacy datapath (uBridge without the tap module): QEMU talks + # UDP to a local tunnel, and uBridge relays it to the peer. + if adapter_number not in self._local_udp_tunnels: + self._local_udp_tunnels[adapter_number] = self._create_local_udp_tunnel() + tunnel_nio = self._local_udp_tunnels[adapter_number][0] + network_options.extend( + [ + "-netdev", + "socket,id=gns3-{},udp={}:{},localaddr={}:{}".format( + adapter_number, tunnel_nio.rhost, tunnel_nio.rport, "127.0.0.1", tunnel_nio.lport + ), + ] + ) return network_options diff --git a/tests/compute/qemu/test_qemu_kernel_datapath.py b/tests/compute/qemu/test_qemu_kernel_datapath.py new file mode 100644 index 000000000..a568e4327 --- /dev/null +++ b/tests/compute/qemu/test_qemu_kernel_datapath.py @@ -0,0 +1,348 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +""" +Kernel-datapath wiring for QEMU: the persistent TAP every adapter owns, its +netdev, kernel-link enslavement, relay attach on the anchor, capture, and the +legacy socket-netdev fallback for a uBridge without the tap module. +""" + +import os +import stat +import sys + +import pytest +import pytest_asyncio + +from unittest.mock import MagicMock, patch + +from tests.utils import asyncio_patch, AsyncioMagicMock + +from gns3server.compute.qemu import Qemu +from gns3server.compute.qemu.qemu_vm import QemuVM +from gns3server.compute.qemu.qemu_error import QemuError +from gns3server.compute.nios.nio_bridge import NIOBridge +from gns3server.compute.ubridge.ubridge_error import UbridgeError + + +BRIDGE = "gns3a1b2c3d4e5f" +TAP0 = "gq00010203e0p0" # the vm fixture's node id, adapter 0 + + +@pytest_asyncio.fixture +async def manager(port_manager): + + m = Qemu.instance() + m.port_manager = port_manager + return m + + +@pytest.fixture +def fake_qemu_binary(monkeypatch, tmpdir): + + monkeypatch.setenv("PATH", str(tmpdir)) + bin_path = os.path.join(os.environ["PATH"], "qemu-system-x86_64") + with open(bin_path, "w+") as f: + f.write("1") + os.chmod(bin_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) + return bin_path + + +@pytest_asyncio.fixture(scope="function") +async def vm(compute_project, manager, fake_qemu_binary): + + vm = QemuVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager, qemu_path=fake_qemu_binary) + vm._process_priority = "normal" + vm._ubridge_hypervisor = MagicMock() + vm._ubridge_hypervisor.is_running.return_value = True + vm._ubridge_send = AsyncioMagicMock() + return vm + + +def _running(vm): + """Make is_running() true without a real process.""" + + vm._process = MagicMock(returncode=None) + + +# --------------------------------------------------------------------------- +# TAP lifecycle +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_prepare_tap_datapath_creates_adapter_taps(vm): + """ + The tap module is probed once per start; on success every adapter gets its + persistent TAP, owned by this user (so unprivileged QEMU can open it) and + born down (carrier off until a link attaches). A leftover from a previous + run is swept first. + """ + + await vm._prepare_tap_datapath() + + assert vm._tap_datapath is True + assert vm._kernel_taps == {(0, 0): TAP0} + vm._ubridge_send.assert_any_call('tap create "gq00010203prob"') + vm._ubridge_send.assert_any_call('tap delete "gq00010203prob"') + vm._ubridge_send.assert_any_call(f'tap delete "{TAP0}"') + vm._ubridge_send.assert_any_call(f'tap create "{TAP0}"') + vm._ubridge_send.assert_any_call(f"tap set_owner {TAP0} {os.getuid()}") + vm._ubridge_send.assert_any_call(f'link set "{TAP0}" down') + + +@pytest.mark.asyncio +async def test_prepare_tap_datapath_falls_back_without_the_tap_module(vm): + """ + A uBridge build without the tap module keeps the legacy socket-netdev + datapath — the node still starts, it just cannot carry kernel links. + """ + + async def send(command): + if command.startswith("tap create"): + raise UbridgeError("202-Unknown command 'create'") + + vm._ubridge_send = AsyncioMagicMock(side_effect=send) + with patch.object(vm.project, "emit") as emit: + await vm._prepare_tap_datapath() + + assert vm._tap_datapath is False + assert vm._kernel_taps == {} + assert emit.called + + +@pytest.mark.asyncio +async def test_tap_name_fits_ifnamsiz(vm): + """ + The anchor name has to fit IFNAMSIZ (15) even for a late adapter. + """ + + vm.adapters = 32 + assert len(vm._tap_name(31)) <= 15 + + +@pytest.mark.asyncio +async def test_network_options_use_the_tap_anchor(vm): + """ + On the TAP datapath the netdev is the adapter's anchor, not a UDP socket. + """ + + vm._tap_datapath = True + vm._kernel_taps[(0, 0)] = TAP0 + + options = await vm._network_options() + + assert f"tap,id=gns3-0,ifname={TAP0},script=no,downscript=no" in options + assert not any(option.startswith("socket,") for option in options) + assert not vm._local_udp_tunnels + + +@pytest.mark.asyncio +async def test_network_options_keep_the_legacy_socket_netdev(vm): + """ + Without the tap module nothing changes: the local UDP tunnel into uBridge + is still the adapter's netdev. + """ + + vm._tap_datapath = False + + options = await vm._network_options() + + assert any(option.startswith("socket,id=gns3-0,udp=") for option in options) + assert vm._local_udp_tunnels + + +# --------------------------------------------------------------------------- +# Link attach / detach +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_connect_nio_kernel_link_enslaves_the_tap(vm): + """ + A kernel link enslaves the adapter's TAP into the per-link kernel bridge, + with the bridge brought up first (a bridge that is down keeps its ports + DISABLED and forwards nothing, silently). + """ + + vm._tap_datapath = True + vm._kernel_taps[(0, 0)] = TAP0 + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + + await vm._connect_nio(0, nio) + + vm._ubridge_send.assert_any_call(f'brctl create "{BRIDGE}"') + vm._ubridge_send.assert_any_call(f'link set "{BRIDGE}" up') + vm._ubridge_send.assert_any_call(f'brctl addif "{BRIDGE}" "{TAP0}"') + + +@pytest.mark.asyncio +async def test_connect_nio_relay_uses_the_tap_as_af_packet_endpoint(vm): + """ + A relay link attaches the TAP to the uBridge relay bridge via AF_PACKET + (not the TAP NIO: that one takes the fd QEMU holds). + """ + + vm._tap_datapath = True + vm._kernel_taps[(0, 0)] = TAP0 + nio = vm.manager.create_nio({"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001}) + + await vm._connect_nio(0, nio) + + bridge = f"QEMU-{vm.id}-0" + vm._ubridge_send.assert_any_call(f"bridge create {bridge}") + vm._ubridge_send.assert_any_call(f'link set "{TAP0}" up') + vm._ubridge_send.assert_any_call(f'bridge add_nio_ethernet {bridge} "{TAP0}"') + vm._ubridge_send.assert_any_call(f"bridge add_nio_udp {bridge} 20000 127.0.0.1 20001") + vm._ubridge_send.assert_any_call(f"bridge start {bridge}") + + +@pytest.mark.asyncio +async def test_connect_nio_without_anchor_raises(vm): + """ + A kernel link on an adapter whose TAP does not exist (a VM started before + the TAP datapath) fails with an actionable message. + """ + + vm._tap_datapath = True + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + + with pytest.raises(QemuError, match="no TAP interface"): + await vm._connect_nio(0, nio) + + +@pytest.mark.asyncio +async def test_legacy_datapath_rejects_kernel_link(vm): + """ + The legacy datapath has no anchor to enslave: a kernel link there is an + error, not a silent fallback to the relay. + """ + + vm._tap_datapath = False + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + + with pytest.raises(QemuError, match="legacy relay datapath"): + await vm._connect_nio(0, nio) + + +@pytest.mark.asyncio +async def test_add_nio_binding_on_a_running_vm_attaches_and_brings_the_carrier_up(vm): + _running(vm) + vm._tap_datapath = True + vm._kernel_taps[(0, 0)] = TAP0 + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + + await vm.adapter_add_nio_binding(0, nio) + + vm._ubridge_send.assert_any_call(f'brctl addif "{BRIDGE}" "{TAP0}"') + vm._ubridge_send.assert_any_call(f'link set "{TAP0}" up') + + +@pytest.mark.asyncio +async def test_update_nio_binding_reapplies_filters_on_the_anchor(vm): + """ + A filter update on an attached kernel link reconciles on the anchor + without re-enslaving it (no second addif). + """ + + _running(vm) + vm._tap_datapath = True + vm._kernel_taps[(0, 0)] = TAP0 + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + vm._ethernet_adapters[0].add_nio(0, nio) + nio.filters = {"delay": [10]} + + await vm.adapter_update_nio_binding(0, nio) + + vm._ubridge_send.assert_any_call(f'tc reset "{TAP0}"') + vm._ubridge_send.assert_any_call(f'tc netem set "{TAP0}" delay 10') + assert not any(f'brctl addif' in str(c) for c in vm._ubridge_send.call_args_list) + + +@pytest.mark.asyncio +async def test_remove_nio_binding_detaches_the_anchor(vm): + """ + Removing a kernel link resets the anchor (no impairment survives on the + adapter) and detaches it from the bridge. + """ + + _running(vm) + vm._tap_datapath = True + vm._kernel_taps[(0, 0)] = TAP0 + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + vm._ethernet_adapters[0].add_nio(0, nio) + + with asyncio_patch("gns3server.compute.qemu.qemu_vm.QemuVM.stop_capture"): + await vm.adapter_remove_nio_binding(0) + + vm._ubridge_send.assert_any_call(f'tc reset "{TAP0}"') + vm._ubridge_send.assert_any_call(f'brctl delif "{BRIDGE}" "{TAP0}"') + vm._ubridge_send.assert_any_call(f'brctl delete "{BRIDGE}"') + + +@pytest.mark.asyncio +async def test_stop_ubridge_removes_taps_and_orphan_bridges(vm): + """ + A stopped VM has no links attached, so the TAPs and any per-link bridge + it still holds are removed while the uBridge control channel is up. + """ + + vm._kernel_taps[(0, 0)] = TAP0 + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + vm._ethernet_adapters[0].add_nio(0, nio) + + with asyncio_patch("gns3server.compute.base_node.BaseNode._stop_ubridge") as stop: + await vm._stop_ubridge() + + vm._ubridge_send.assert_any_call(f'tap delete "{TAP0}"') + vm._ubridge_send.assert_any_call(f'brctl delete "{BRIDGE}"') + assert stop.called + assert vm._kernel_taps == {} + assert vm._ubridge_tc_caps is None + + +# --------------------------------------------------------------------------- +# Capture +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_capture_on_a_kernel_link_uses_the_anchor(vm): + vm._tap_datapath = True + vm._kernel_taps[(0, 0)] = TAP0 + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + vm._ethernet_adapters[0].add_nio(0, nio) + + await vm.start_capture(0, "/tmp/test.pcap") + vm._ubridge_send.assert_any_call(f'capture start_kernel {TAP0} "/tmp/test.pcap"') + + await vm.stop_capture(0) + vm._ubridge_send.assert_any_call("capture stop_kernel") + + +@pytest.mark.asyncio +async def test_capture_on_a_relay_link_uses_the_relay_bridge(vm): + vm._tap_datapath = True + vm._kernel_taps[(0, 0)] = TAP0 + nio = vm.manager.create_nio({"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001}) + vm._ethernet_adapters[0].add_nio(0, nio) + + await vm.start_capture(0, "/tmp/test.pcap") + vm._ubridge_send.assert_any_call(f'bridge start_capture QEMU-{vm.id}-0 "/tmp/test.pcap"') + + await vm.stop_capture(0) + vm._ubridge_send.assert_any_call(f"bridge stop_capture QEMU-{vm.id}-0")