feat(qemu): anchor QEMU adapters on a persistent TAP

QEMU had no host-side interface at all: every adapter was a local UDP tunnel
into uBridge (-netdev socket plus two UDP ports per adapter), so a link could
only ever be a userspace relay. Each adapter now owns a persistent TAP
(gq{node_id[:8]}e{adapter}p{port}), created by uBridge before QEMU is launched
and handed to this user, and the netdev is that TAP -- the anchor role Docker's
veth host end plays. Links attach to it:

* kernel link (NIOBridge): brctl create/up/addif enslaves the TAP into the
  per-link bridge and capture, markers and tc filters are installed on it,
  verbatim through the shared KernelDatapathMixin -- a TAP and a veth host end
  are interchangeable there (an egress qdisc on either impairs traffic entering
  the node, and PACKET_OUTGOING on either is the node receiving);
* relay link: bridge add_nio_ethernet plus add_nio_udp -- AF_PACKET, never the
  TAP NIO, which would take the fd QEMU itself holds.

The netdev type is fixed at launch, so this is all-or-nothing per VM: on the
TAP datapath every link type attaches to (or detaches from) a running VM,
including switching a link between the two datapaths. Stop un-persists the
TAPs (QEMU's open fd keeps each device alive until it exits) and removes the
per-link bridges; start sweeps leftovers first. A uBridge without the tap
module keeps the previous socket-netdev datapath, so an unupgraded install is
unaffected -- such a node simply cannot carry kernel links.
This commit is contained in:
YueGuobin 2026-09-30 18:31:13 +08:00
parent 12d8abce45
commit 7d2d8c6747
No known key found for this signature in database
4 changed files with 650 additions and 53 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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")