mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-12 21:45:41 +03:00
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:
parent
12d8abce45
commit
7d2d8c6747
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
348
tests/compute/qemu/test_qemu_kernel_datapath.py
Normal file
348
tests/compute/qemu/test_qemu_kernel_datapath.py
Normal 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")
|
||||
Loading…
x
Reference in New Issue
Block a user