From 7201a870dabf0b40bbb7e7177d2a1a0232e4ff54 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 1 Oct 2026 11:55:04 +0800 Subject: [PATCH] feat(dynamips): anchor every Ethernet port on a persistent TAP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dynamips links can now ride the kernel datapath like Docker, QEMU and IOU: each Ethernet slot/port gets a persistent TAP anchor (gd{id}e{slot}p{port}), created through uBridge's tap module, and the Dynamips hypervisor opens it with nio create_tap — the hypervisor holds the fd, exactly QEMU's shape. Links attach the anchor to their per-link kernel bridge; the legacy UDP tunnel remains the relay datapath for every other port. Anchor lifecycle: born at node start (tap module probed with one create/delete cycle, sweep of stale devices, ownership handed to the server user, born down); a kernel link opens the anchor in the hypervisor, binds the slot/port and enslaves it; removing the link releases the fd again (nio delete) and detaches. A link bound while the router is stopped is wired by the next start (deferred wiring). Node close follows QEMU's stop order — the hypervisor's TAP NIOs go before tap delete, since it is Dynamips, not uBridge, that holds the fds. Stop is not close: vm stop only halts the emulated router, so the hypervisor, the TAP fds, the bindings and the per-link bridges all survive a stop/start untouched (attach skips ports that still hold their TAP NIO). Port selection follows the adapter registry: ETHERNET_ADAPTERS and ETHERNET_WICS name the models whose ports carry Ethernet frames (WIC-1ENET included, at its Dynamips port number); serial, ATM and POS ports never anchor, and a kernel link there errors with the relay-datapath message instead of half-wiring. Adapters hot-added to a running router (OIR) get their anchors at once. The compute NIO routes accept nio_bridge alongside nio_udp (the same widening QEMU needed), carry the endpoint's suspend flag on update (it is the anchor's admin state on a kernel link), skip the hypervisor delete for a kernel NIO (its TAP NIO was already released by the slot removal), and the close path leaves NIOBridge instances to _stop_ubridge. --- .../api/routes/compute/dynamips_nodes.py | 28 +- gns3server/compute/dynamips/__init__.py | 12 +- .../compute/dynamips/adapters/adapter.py | 31 + gns3server/compute/dynamips/nodes/router.py | 347 +++++++++- .../dynamips/test_dynamips_kernel_datapath.py | 614 ++++++++++++++++++ 5 files changed, 1019 insertions(+), 13 deletions(-) create mode 100644 tests/compute/dynamips/test_dynamips_kernel_datapath.py diff --git a/gns3server/api/routes/compute/dynamips_nodes.py b/gns3server/api/routes/compute/dynamips_nodes.py index 99c1b53cd..419d96b1a 100644 --- a/gns3server/api/routes/compute/dynamips_nodes.py +++ b/gns3server/api/routes/compute/dynamips_nodes.py @@ -29,6 +29,7 @@ from fastapi.responses import StreamingResponse from gns3server import schemas from gns3server.compute.dynamips import Dynamips from gns3server.compute.dynamips.nodes.router import Router +from gns3server.compute.nios.nio_bridge import NIOBridge from .dependencies.authentication import compute_authentication, ws_compute_authentication @@ -173,12 +174,15 @@ async def reload_router(node: Router = Depends(dep_node)) -> None: @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, - response_model=schemas.UDPNIO, + response_model=Union[schemas.UDPNIO, schemas.BridgeNIO], dependencies=[Depends(compute_authentication)], ) async def create_nio( - adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: Router = Depends(dep_node) -) -> schemas.UDPNIO: + adapter_number: int, + port_number: int, + nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO], + node: Router = Depends(dep_node), +) -> Union[schemas.UDPNIO, schemas.BridgeNIO]: """ Add a NIO (Network Input/Output) to the node. """ @@ -191,12 +195,15 @@ async def create_nio( @router.put( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, - response_model=schemas.UDPNIO, + response_model=Union[schemas.UDPNIO, schemas.BridgeNIO], dependencies=[Depends(compute_authentication)], ) async def update_nio( - adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: Router = Depends(dep_node) -) -> schemas.UDPNIO: + adapter_number: int, + port_number: int, + nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO], + node: Router = Depends(dep_node), +) -> Union[schemas.UDPNIO, schemas.BridgeNIO]: """ Update a NIO (Network Input/Output) on the node. """ @@ -206,6 +213,10 @@ async def update_nio( if nio_data.filters: nio.filters = nio_data.filters nio.markers = nio_data.markers or {} + # Suspend is what the compute turns into an admin-down anchor on a + # kernel link (native carrier), so it must reach the NIO like it does + # on the Docker/QEMU/IOU routes. + nio.suspend = getattr(nio_data, "suspend", None) or False await node.slot_update_nio_binding(adapter_number, port_number, nio) return nio.asdict() @@ -221,7 +232,10 @@ async def delete_nio(adapter_number: int, port_number: int, node: Router = Depen """ nio = await node.slot_remove_nio_binding(adapter_number, port_number) - await nio.delete() + if not isinstance(nio, NIOBridge): + # A kernel NIO has no hypervisor side left: its TAP NIO was deleted + # by the slot binding removal itself. + await nio.delete() @router.post( diff --git a/gns3server/compute/dynamips/__init__.py b/gns3server/compute/dynamips/__init__.py index 2013f0c35..b9674247f 100644 --- a/gns3server/compute/dynamips/__init__.py +++ b/gns3server/compute/dynamips/__init__.py @@ -39,6 +39,7 @@ from gns3server.utils.asyncio import subprocess_check_output, wait_run_in_execut from gns3server.utils.interfaces import is_interface_up from ..base_manager import BaseManager +from ..nios.nio_bridge import NIOBridge from ..port_manager import PortManager # Adapters @@ -364,7 +365,16 @@ class Dynamips(BaseManager): """ nio = None - if nio_settings["type"] == "nio_udp": + if nio_settings["type"] == "nio_bridge": + # Kernel-datapath NIO: the router binds the port's anchor TAP to + # a hypervisor nio_tap and enslaves it into this per-link bridge + # (Router._attach_kernel_nio). No hypervisor NIO is created here. + nio = NIOBridge(nio_settings["bridge"]) + nio.filters = nio_settings.get("filters", {}) + nio.markers = nio_settings.get("markers", {}) + nio.suspend = nio_settings.get("suspend", False) + return nio + elif nio_settings["type"] == "nio_udp": lport = nio_settings["lport"] rhost = nio_settings["rhost"] rport = nio_settings["rport"] diff --git a/gns3server/compute/dynamips/adapters/adapter.py b/gns3server/compute/dynamips/adapters/adapter.py index afa2a3e52..ced27bb82 100644 --- a/gns3server/compute/dynamips/adapters/adapter.py +++ b/gns3server/compute/dynamips/adapters/adapter.py @@ -15,6 +15,37 @@ # along with this program. If not, see . +# Adapter and WIC models whose ports carry Ethernet frames (by __str__ +# name, matching the controller's port matrix). Only these ports get a +# kernel-datapath anchor TAP: a TAP cannot carry the serial/ATM/POS +# encapsulations the other models speak. +ETHERNET_ADAPTERS = frozenset( + { + "C1700-MB-1FE", + "C2600-MB-1E", + "C2600-MB-1FE", + "C2600-MB-2E", + "C2600-MB-2FE", + "C7200-IO-2FE", + "C7200-IO-FE", + "C7200-IO-GE-E", + "GT96100-FE", + "Leopard-2FE", + "NM-16ESW", + "NM-1E", + "NM-1FE-TX", + "NM-4E", + "PA-2FE-TX", + "PA-4E", + "PA-8E", + "PA-FE-TX", + "PA-GE", + } +) + +ETHERNET_WICS = frozenset({"WIC-1ENET"}) + + class Adapter: """ Base class for adapters. diff --git a/gns3server/compute/dynamips/nodes/router.py b/gns3server/compute/dynamips/nodes/router.py index c82e39cbf..6c8afaf5f 100644 --- a/gns3server/compute/dynamips/nodes/router.py +++ b/gns3server/compute/dynamips/nodes/router.py @@ -22,6 +22,7 @@ http://github.com/GNS3/dynamips/blob/master/README.hypervisor#L77 import asyncio import base64 import binascii +import contextlib import glob import logging import os @@ -37,10 +38,16 @@ from gns3server.utils.hostname import is_ios_hostname_valid from gns3server.utils.images import md5sum from ...base_node import BaseNode +from ...error import NodeError +from ...kernel_datapath import KernelDatapathMixin +from ...nios.nio_bridge import NIOBridge +from ...nios.nio_tap import NIOTAP +from ...ubridge.ubridge_error import UbridgeError +from ..adapters.adapter import ETHERNET_ADAPTERS, ETHERNET_WICS from ..dynamips_error import DynamipsError -class Router(BaseNode): +class Router(KernelDatapathMixin, BaseNode): """ Dynamips router implementation. @@ -123,6 +130,13 @@ class Router(BaseNode): self._slots = [] self._ghost_flag = ghost_flag self._memory_watcher = None + # Kernel datapath: the persistent anchor TAP per Ethernet slot/port + # (in _kernel_taps) and the hypervisor TAP NIO holding each anchor + # open while a kernel link binds the port (in _tap_nios). + self._kernel_taps = {} + self._tap_nios = {} + self._tap_datapath = False + self._ubridge_tc_caps = None if not ghost_flag: if not dynamips_id: @@ -320,7 +334,16 @@ class Router(BaseNode): # an empty private-config can prevent a router to boot. private_config_path = "" - await self._hypervisor.send(f'vm set_config "{self._name}" "{startup_config_path}" "{private_config_path}"') + # uBridge owns the kernel datapath's anchor TAPs; start it and + # create them before the VM boots, so kernel links bound while + # the node was stopped wire themselves as IOS comes up. + await self._prepare_tap_datapath() + + await self._hypervisor.send( + 'vm set_config "{name}" "{startup}" "{private}"'.format( + name=self._name, startup=startup_config_path, private=private_config_path + ) + ) await self._hypervisor.send(f'vm start "{self._name}"') self.status = "started" log.debug(f'router "{self._name}" [{self._id}] has been started') @@ -421,7 +444,9 @@ class Router(BaseNode): for adapter in self._slots: if adapter is not None: for nio in adapter.ports.values(): - if nio: + # NIOBridge has no hypervisor side to close here — its + # release runs in _stop_ubridge below. + if nio and not isinstance(nio, NIOBridge): await nio.close() await self._stop_ubridge() @@ -1143,6 +1168,11 @@ class Router(BaseNode): log.debug(f'Router "{self._name}" [{self._id}]: OIR start event sent to slot {slot_number}') + if self._tap_datapath: + # The new adapter's Ethernet ports need their anchor TAPs now + # (not at next start) for kernel links to attach at once. + await self._create_taps() + async def slot_remove_binding(self, slot_number): """ Removes a slot binding (a module from a slot). @@ -1281,6 +1311,19 @@ class Router(BaseNode): if not adapter.port_exists(port_number): raise DynamipsError(f"Port {port_number} does not exist on adapter {adapter}") + if isinstance(nio, NIOBridge): + # Kernel datapath: the port's anchor TAP is opened in the + # hypervisor and enslaved into the NIO's per-link bridge (the + # wiring is deferred to the node's start when no anchors exist). + adapter.add_nio(port_number, nio) + await self._attach_kernel_nio(slot_number, port_number, nio) + log.debug( + 'Router "{name}" [{id}]: {nio} bound to port {slot_number}/{port_number}'.format( + name=self._name, id=self._id, nio=nio, slot_number=slot_number, port_number=port_number + ) + ) + return + try: await self._hypervisor.send(f'vm slot_add_nio_binding "{self._name}" {slot_number} {port_number} {nio}') except DynamipsError: @@ -1302,6 +1345,15 @@ class Router(BaseNode): :param nio: NIO instance to add to the slot/port """ + if isinstance(nio, NIOBridge): + # Filters, markers or suspend changed on a kernel link: re-apply + # on the anchor, no re-binding and no re-enslaving. A NIO bound + # while the node was stopped carries its state; start applies it. + anchor = self._kernel_host_ifc(slot_number, port_number) + if anchor is not None and self.ubridge: + await self._kernel_update(anchor, nio) + await self._set_adapter_carrier(slot_number, not nio.suspend, port_number) + return await nio.update() async def slot_remove_nio_binding(self, slot_number, port_number): @@ -1326,12 +1378,27 @@ class Router(BaseNode): raise DynamipsError(f"Port {port_number} does not exist on adapter {adapter}") await self.stop_capture(slot_number, port_number) - await self.slot_disable_nio(slot_number, port_number) - await self._hypervisor.send(f'vm slot_remove_nio_binding "{self._name}" {slot_number} {port_number}') nio = adapter.get_nio(port_number) if nio is None: return + + if isinstance(nio, NIOBridge): + await self._remove_kernel_nio_binding(slot_number, port_number, nio) + adapter.remove_nio(port_number) + log.debug( + 'Router "{name}" [{id}]: {nio} removed from port {slot_number}/{port_number}'.format( + name=self._name, id=self._id, nio=nio, slot_number=slot_number, port_number=port_number + ) + ) + return nio + + await self.slot_disable_nio(slot_number, port_number) + await self._hypervisor.send( + 'vm slot_remove_nio_binding "{name}" {slot_number} {port_number}'.format( + name=self._name, slot_number=slot_number, port_number=port_number + ) + ) await nio.close() adapter.remove_nio(port_number) @@ -1421,6 +1488,21 @@ class Router(BaseNode): if not nio: raise DynamipsError(f"Port {slot_number}/{port_number} is not connected") + if isinstance(nio, NIOBridge): + # Kernel link: capture on the port's TAP anchor (AF_PACKET), + # not in the Dynamips NIO (this link has no Dynamips NIO). + nio.start_packet_capture(output_file, data_link_type) + if self.ubridge: + anchor = self._kernel_host_ifc(slot_number, port_number) + if anchor is not None: + await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"') + log.debug( + 'Router "{name}" [{id}]: starting packet capture on port {slot_number}/{port_number}'.format( + name=self._name, id=self._id, slot_number=slot_number, port_number=port_number + ) + ) + return + if nio.input_filter[0] is not None and nio.output_filter[0] is not None: raise DynamipsError(f"Port {port_number} has already a filter applied on {adapter}") await nio.start_packet_capture(output_file, data_link_type) @@ -1448,10 +1530,265 @@ class Router(BaseNode): if not nio.capturing: return + + if isinstance(nio, NIOBridge): + nio.stop_packet_capture() + if self.ubridge and self._kernel_host_ifc(slot_number, port_number) is not None: + await self._ubridge_send("capture stop_kernel") + return await nio.stop_packet_capture() log.debug(f'Router "{self._name}" [{self._id}]: stopping packet capture on port {slot_number}/{port_number}') + # ------------------------------------------------------------------ + # Kernel datapath: the port anchor is a persistent TAP held by the + # Dynamips hypervisor (nio create_tap) — QEMU's shape, not IOU's + # ------------------------------------------------------------------ + + def _tap_name(self, slot_number, port_number): + """ + Deterministic anchor TAP name for a slot/port. The ``gd`` prefix + keeps it out of the ``gns3`` bridge/TAP name space (and apart from + Docker's ``gv``/``gc``, QEMU's ``gq`` and IOU's ``gi``); 8 hex chars + of the node id plus slot/port keep it unique and within IFNAMSIZ + (15), even for WIC port numbers (16, 32, 48). + """ + + return f"gd{self._id.replace('-', '')[:8]}e{slot_number}p{port_number}" + + def _kernel_host_ifc(self, slot_number, port_number=0): + """ + The persistent TAP this Ethernet slot/port owns, or None for serial, + ATM or POS ports (never anchored) and before the router started. + """ + + return self._kernel_taps.get((slot_number, port_number)) + + def _kernel_anchors(self): + """ + Every anchor TAP this router currently owns. + """ + + return set(self._kernel_taps.values()) + + def _kernel_error(self, message): + return DynamipsError(message) + + def _ethernet_slot_ports(self): + """ + Every (slot, port) that can carry Ethernet frames: slot adapters + whose model is Ethernet, plus WIC-1ENET ports (numbered from 16 per + WIC slot, the Dynamips convention) in any motherboard. + """ + + ports = [] + for slot_number, adapter in enumerate(self._slots): + if adapter is None: + continue + if str(adapter) in ETHERNET_ADAPTERS: + ports.extend((slot_number, port_number) for port_number in adapter.ports) + for wic_slot_number, wic in enumerate(adapter.wics or []): + if wic is not None and str(wic) in ETHERNET_WICS: + ports.append((slot_number, 16 * (wic_slot_number + 1))) + return ports + + @property + def _ethernet_adapters(self): + """ + Slot adapters owning at least one anchor — the mixin's per-link + bridge sweep walks them. Dynamips ports live in slot adapters (WIC + ports included), so this derives from the anchor map rather than + the slot list. + """ + + adapters = [] + for slot_number, _port_number in self._kernel_taps: + if slot_number < len(self._slots): + adapter = self._slots[slot_number] + if adapter is not None and adapter not in adapters: + adapters.append(adapter) + return adapters + + async def _prepare_tap_datapath(self): + """ + Start uBridge and create the persistent TAP every Ethernet slot/port + owns — the anchor role QEMU's TAPs and Docker's veth host ends play. + The Dynamips hypervisor opens the device (ownership handed to this + user by _create_taps), so it needs no privileges of its own to hold + the fd. A uBridge without the tap module keeps this router on the + relay datapath: every link rides the NIOUDP tunnel, exactly as + before. + + Nothing here is undone at node stop — the hypervisor, its NIO + bindings and the per-link kernel bridges all outlive ``vm stop``, + which is what makes a stopped router's links self-heal on the next + start. The anchors are only retired when the node closes. + """ + + if self._ghost_flag: + # Ghost IOS images share RAM with a real router and never link. + return + + try: + await self._start_ubridge() + except NodeError as e: + # A Dynamips router without links never needed uBridge; keep the + # node bootable and let the relay raise at link time (as before). + log.warning("Router '%s': %s", self._name, e) + return + + # uBridge (re)started: the tc capabilities must be probed again. + self._ubridge_tc_caps = None + if not self._tap_datapath: + probe = f"gd{self._id.replace('-', '')[:8]}prob" + try: + await self._ubridge_send(f'tap create "{probe}"') + except UbridgeError as e: + message = ( + f"Router '{self._name}': uBridge cannot create persistent TAPs ({e}); " + "this node runs on the relay datapath and cannot carry kernel links" + ) + log.warning(message) + self.project.emit("log.warning", {"message": message}) + return + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'tap delete "{probe}"') + self._tap_datapath = True + + await self._create_taps() + # Kernel links bound while the router was stopped could not wire (no + # anchors existed); wire them now, before IOS comes up. + for slot_number, adapter in enumerate(self._slots): + if adapter is None: + continue + for port_number, nio in list(adapter.ports.items()): + if isinstance(nio, NIOBridge): + await self._attach_kernel_nio(slot_number, port_number, nio) + + async def _create_taps(self): + """ + Create the persistent TAP every Ethernet slot/port owns. Each TAP + starts DOWN (carrier off until a kernel link attaches); uBridge + creates the device and hands ownership to this user, so the + unprivileged Dynamips hypervisor can open it. A port whose anchor + already exists (a restart, or an adapter hot-added to a running + router) keeps it — an anchor is never recreated under a live link. + A persistent TAP outlives its creator, so a leftover from a previous + run (crash, kill) is swept first. + """ + + for slot_number, port_number in self._ethernet_slot_ports(): + if (slot_number, port_number) in self._kernel_taps: + continue + tap = self._tap_name(slot_number, port_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 hypervisor would fail + # to bind its NIO, which is worth surfacing at that point. + log.warning("Router '%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[(slot_number, port_number)] = tap + + async def _attach_kernel_nio(self, slot_number, port_number, nio): + """ + Wire a kernel link's NIO (NIOBridge) on a port: open the port's + anchor TAP in the Dynamips hypervisor (``nio create_tap`` — the + hypervisor, not uBridge, holds the fd, the same shape as QEMU), bind + it to the slot/port, then enslave the anchor into the per-link + kernel bridge — the shared mixin flow. On a router that is not + running the wiring is deferred to its start (no anchors exist yet). + A port whose wiring survived a stop (nothing is torn down there) is + left alone: re-binding would collide on both hypervisor and bridge. + """ + + if (slot_number, port_number) in self._tap_nios: + return + anchor = self._kernel_host_ifc(slot_number, port_number) + if anchor is None: + if self.status != "started": + return + raise self._kernel_error( + "Port {slot}/{port} of router '{name}' runs on the relay datapath " + "(this uBridge cannot create persistent TAPs) and cannot carry a kernel link".format( + slot=slot_number, port=port_number, name=self._name + ) + ) + + tap_nio = NIOTAP(self._hypervisor, anchor) + await tap_nio.create() + try: + await self._hypervisor.send( + 'vm slot_add_nio_binding "{name}" {slot_number} {port_number} {nio}'.format( + name=self._name, slot_number=slot_number, port_number=port_number, nio=tap_nio.name + ) + ) + except DynamipsError: + with contextlib.suppress(DynamipsError): + await tap_nio.delete() + raise + await self.slot_enable_nio(slot_number, port_number) + self._tap_nios[(slot_number, port_number)] = tap_nio + await self._kernel_attach(anchor, nio) + await self._set_adapter_carrier(slot_number, not nio.suspend, port_number) + + async def _remove_kernel_nio_binding(self, slot_number, port_number, nio): + """ + Release a kernel link's NIO: tear the anchor out of the per-link + kernel bridge (markers, tc, delif — the shared mixin flow), then + unbind the port and delete the hypervisor's TAP NIO, releasing the + anchor fd. The anchor device itself survives for the next link. + """ + + if self.ubridge and self._kernel_host_ifc(slot_number, port_number) is not None: + await self._remove_kernel_nio(nio, slot_number, port_number) + tap_nio = self._tap_nios.pop((slot_number, port_number), None) + if tap_nio is not None: + with contextlib.suppress(DynamipsError, OSError): + await self.slot_disable_nio(slot_number, port_number) + with contextlib.suppress(DynamipsError, OSError): + await self._hypervisor.send( + 'vm slot_remove_nio_binding "{name}" {slot_number} {port_number}'.format( + name=self._name, slot_number=slot_number, port_number=port_number + ) + ) + with contextlib.suppress(DynamipsError, OSError): + await tap_nio.delete() + + async def _stop_ubridge(self): + """ + Stops uBridge, retiring the kernel datapath's host state first — in + the QEMU order, since here it is the Dynamips hypervisor (not + uBridge) that holds the anchor fds: the per-link kernel bridges need + the control channel, and the anchor TAPs (persistent devices) must + be deleted through it after the hypervisor's TAP NIOs are gone, or a + device Dynamips still holds open would outlive its retirement. The + next start spawns a fresh uBridge, so the tc capabilities must be + probed again. + """ + + if self.ubridge: + for (slot_number, port_number), tap_nio in list(self._tap_nios.items()): + with contextlib.suppress(DynamipsError, OSError): + await self._hypervisor.send( + 'vm slot_remove_nio_binding "{name}" {slot_number} {port_number}'.format( + name=self._name, slot_number=slot_number, port_number=port_number + ) + ) + with contextlib.suppress(DynamipsError, OSError): + await tap_nio.delete() + self._tap_nios.clear() + 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() + self._ubridge_tc_caps = None + await super()._stop_ubridge() + def _create_slots(self, numslots): """ Creates the appropriate number of slots for this router. diff --git a/tests/compute/dynamips/test_dynamips_kernel_datapath.py b/tests/compute/dynamips/test_dynamips_kernel_datapath.py new file mode 100644 index 000000000..b204f0dc5 --- /dev/null +++ b/tests/compute/dynamips/test_dynamips_kernel_datapath.py @@ -0,0 +1,614 @@ +# +# 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 Dynamips routers: the persistent TAP every +Ethernet slot/port owns (Ethernet adapters and WIC-1ENET ports only — serial, +ATM and POS adapters never anchor), the hypervisor TAP NIO (nio create_tap) +that holds the anchor open, per-link enslavement, the deferred wiring of +kernel links bound while the node was stopped, and the stop order (the +hypervisor's fds released before the TAPs are unpersisted — QEMU's order, +since here it is Dynamips that holds them). +""" + +import os +import shutil +import contextlib + +import pytest +import pytest_asyncio + +from unittest.mock import MagicMock, patch + +from tests.utils import AsyncioMagicMock + +from gns3server.compute.dynamips import Dynamips +from gns3server.compute.dynamips.nodes.router import Router +from gns3server.compute.dynamips.dynamips_error import DynamipsError +from gns3server.compute.dynamips.adapters.gt96100_fe import GT96100_FE +from gns3server.compute.dynamips.adapters.pa_4t import PA_4T +from gns3server.compute.dynamips.adapters.pa_fe_tx import PA_FE_TX +from gns3server.compute.dynamips.adapters.wic_1enet import WIC_1ENET +from gns3server.compute.ubridge.ubridge_error import UbridgeError +from gns3server.compute.nios.nio_bridge import NIOBridge + + +NODE_ID = "00010203-0405-0607-0809-0a0b0c0d0e0f" +BRIDGE = "gns3a1b2c3d4e5f" +TAP00 = "gd00010203e0p0" # the router fixture's node id, slot 0 port 0 +TAP01 = "gd00010203e0p1" +TAP016 = "gd00010203e0p16" # WIC-1ENET port in WIC slot 0 + + +class FakeDynamipsHypervisor: + """Records the hypervisor protocol; answers status queries.""" + + def __init__(self, status_code=2): + self.commands = [] + self._status_code = status_code + + def is_running(self): + return True + + async def send(self, command): + self.commands.append(command) + if command.startswith("vm get_status"): + return [str(self._status_code)] + return ["100-OK"] + + +@pytest_asyncio.fixture +async def manager(port_manager): + + m = Dynamips.instance() + m.port_manager = port_manager + return m + + +@pytest_asyncio.fixture +async def router(compute_project, manager): + + router = Router("R1", NODE_ID, compute_project, manager) + router._hypervisor = FakeDynamipsHypervisor() + router._ubridge_hypervisor = MagicMock() + router._ubridge_hypervisor.is_running.return_value = True + router._ubridge_send = AsyncioMagicMock() + # slot 0: a 2-port Ethernet motherboard with a WIC-1ENET in WIC slot 0 + # (port 16); slot 1: a serial PA that must never anchor. + router._create_slots(2) + router._slots[0] = GT96100_FE() + router._slots[0]._wics[0] = WIC_1ENET() + router._slots[1] = PA_4T() + yield router + # compute_project is the shared, id-fixed project every compute test + # file reuses (ProjectManager caches by id); leave its dynamips module + # directory as this fixture found it (absent) — a later manager test + # creates it with os.makedirs and no exist_ok. + shutil.rmtree(router._working_directory, ignore_errors=True) + with contextlib.suppress(OSError): + os.rmdir(compute_project.module_working_path(manager.module_name.lower())) + + +async def _nio(router, **kwargs): + settings = {"type": "nio_bridge", "bridge": BRIDGE} + settings.update(kwargs) + return await router.manager.create_nio(router, settings) + + +def _hypervisor_commands(router): + return list(router._hypervisor.commands) + + +def _ubridge_commands(router): + return [str(c[0][0]) if c[0] else "" for c in router._ubridge_send.call_args_list] + + +# --------------------------------------------------------------------------- +# Anchor topology: which ports anchor at all +# --------------------------------------------------------------------------- + + +def test_ethernet_slot_ports_skip_serial_and_include_wics(router): + """ + Only Ethernet-carrying ports anchor: the PA's two ports plus the + WIC-1ENET port (numbered from 16 per WIC slot), never the serial PA. + """ + + assert router._ethernet_slot_ports() == [(0, 0), (0, 1), (0, 16)] + + +def test_tap_name_fits_ifnamsiz(router): + """ + The anchor name has to fit IFNAMSIZ (15) even for WIC port numbers. + """ + + assert len(router._tap_name(6, 48)) <= 15 + + +def test_kernel_host_ifc_is_none_before_start(router): + assert router._kernel_host_ifc(0, 0) is None + + +# --------------------------------------------------------------------------- +# Anchor lifecycle +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_prepare_tap_datapath_creates_one_tap_per_ethernet_port(router): + """ + On a capable uBridge every Ethernet slot/port gets its persistent TAP — + swept of leftovers first, ownership handed to this user (so the + unprivileged hypervisor can open it), born down. + """ + + router._ubridge_send.reset_mock() + await router._prepare_tap_datapath() + + assert router._tap_datapath is True + assert router._kernel_taps == {(0, 0): TAP00, (0, 1): TAP01, (0, 16): TAP016} + router._ubridge_send.assert_any_call(f'tap delete "{TAP00}"') + router._ubridge_send.assert_any_call(f'tap create "{TAP00}"') + router._ubridge_send.assert_any_call(f"tap set_owner {TAP016} {os.getuid()}") + router._ubridge_send.assert_any_call(f'link set "{TAP016}" down') + + +@pytest.mark.asyncio +async def test_prepare_tap_datapath_falls_back_without_the_tap_module(router): + """ + A uBridge without the tap module keeps the router on the relay datapath — + it still starts, it just cannot carry kernel links. + """ + + async def refuse_probe(command): + if "prob" in command: + raise UbridgeError("Unknown command") + return ["100-OK"] + + router._ubridge_send.side_effect = refuse_probe + with patch.object(router.project, "emit") as emit: + await router._prepare_tap_datapath() + + assert router._tap_datapath is False + assert router._kernel_taps == {} + assert emit.called + + +@pytest.mark.asyncio +async def test_prepare_tap_datapath_skips_ghost_routers(compute_project, manager): + """ + Ghost IOS images share RAM with a real router and never link: no uBridge, + no anchors. + """ + + ghost = Router("Ghost", NODE_ID, compute_project, manager, ghost_flag=True) + ghost._start_ubridge = AsyncioMagicMock() + await ghost._prepare_tap_datapath() + + assert ghost._tap_datapath is False + assert not ghost._start_ubridge.called + + +@pytest.mark.asyncio +async def test_prepare_tap_datapath_keeps_existing_anchors_across_a_restart(router): + """ + Nothing is undone at node stop, so a restart must not recreate an anchor + a surviving kernel link is still enslaved to. + """ + + await router._prepare_tap_datapath() + router._ubridge_send.reset_mock() + + await router._prepare_tap_datapath() + + assert router._kernel_taps == {(0, 0): TAP00, (0, 1): TAP01, (0, 16): TAP016} + assert not any("tap create" in c or "tap delete" in c for c in _ubridge_commands(router)) + + +@pytest.mark.asyncio +async def test_prepare_tap_datapath_wires_nios_bound_while_stopped(router): + """ + A kernel link bound to a stopped router could only be stored (no anchors + existed); the start wires it — the hypervisor opens the anchor and binds + the port, the anchor joins the per-link bridge. + """ + + router.status = "stopped" + nio = await _nio(router) + router._slots[0].add_nio(0, nio) + + router._ubridge_send.reset_mock() + router._hypervisor.commands.clear() + await router._prepare_tap_datapath() + + hypervisor = _hypervisor_commands(router) + assert any(c.startswith("nio create_tap tap-") and c.endswith(f"{TAP00}") for c in hypervisor) + assert any(c.startswith(f"vm slot_add_nio_binding \"R1\" 0 0 tap-") for c in hypervisor) + router._ubridge_send.assert_any_call(f'brctl addif "{BRIDGE}" "{TAP00}"') + assert (0, 0) in router._tap_nios + + +# --------------------------------------------------------------------------- +# Link attach / detach +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_attach_kernel_nio_opens_the_anchor_in_the_hypervisor_and_enslaves_it(router): + """ + A kernel link opens the port's anchor TAP in the Dynamips hypervisor + (Dynamips holds the fd — QEMU's shape), binds it to the slot/port, then + enslaves the anchor into the per-link kernel bridge. + """ + + router.status = "started" + router._kernel_taps[(0, 0)] = TAP00 + nio = await _nio(router) + + await router._attach_kernel_nio(0, 0, nio) + + hypervisor = _hypervisor_commands(router) + assert any(c.startswith("nio create_tap tap-") and c.endswith(f"{TAP00}") for c in hypervisor) + assert any(c.startswith('vm slot_add_nio_binding "R1" 0 0 tap-') for c in hypervisor) + router._ubridge_send.assert_any_call(f'brctl create "{BRIDGE}"') + router._ubridge_send.assert_any_call(f'link set "{BRIDGE}" up') + router._ubridge_send.assert_any_call(f'brctl addif "{BRIDGE}" "{TAP00}"') + router._ubridge_send.assert_any_call(f'link set "{TAP00}" up') + + +@pytest.mark.asyncio +async def test_attach_kernel_nio_without_anchor_on_a_running_node_raises(router): + """ + A kernel link on a serial port (or a router that never anchored) is an + error naming the relay datapath, not a silent fallback. + """ + + router.status = "started" + nio = await _nio(router) + + with pytest.raises(DynamipsError, match="relay datapath"): + await router._attach_kernel_nio(1, 0, nio) + + +@pytest.mark.asyncio +async def test_attach_kernel_nio_defers_on_a_stopped_node(router): + """ + On a stopped router there are no anchors yet: the NIO is stored and the + wiring happens at start (test above), so nothing is sent. + """ + + router.status = "stopped" + nio = await _nio(router) + + await router._attach_kernel_nio(1, 0, nio) + + assert _hypervisor_commands(router) == [] + assert not router._ubridge_send.called + + +@pytest.mark.asyncio +async def test_attach_kernel_nio_is_idempotent_across_a_restart(router): + """ + A stop tears nothing down, so a wired port must not re-bind on the next + start: a second nio create_tap would orphan the first NIO's fd and the + re-enslavement would collide on the bridge. + """ + + router.status = "started" + router._kernel_taps[(0, 0)] = TAP00 + nio = await _nio(router) + await router._attach_kernel_nio(0, 0, nio) + hypervisor_first = list(_hypervisor_commands(router)) + router._ubridge_send.reset_mock() + + await router._attach_kernel_nio(0, 0, nio) + + assert _hypervisor_commands(router) == hypervisor_first + assert not router._ubridge_send.called + + +@pytest.mark.asyncio +async def test_add_nio_binding_relay_keeps_the_udp_tunnel(router): + """ + A relay link is wired exactly as before: the hypervisor NIO is a UDP NIO + tunneled through loopback into the node's uBridge relay. + """ + + router.status = "started" + nio = await router.manager.create_nio( + router, {"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001} + ) + + await router.slot_add_nio_binding(0, 0, nio) + + hypervisor = _hypervisor_commands(router) + assert any(c.startswith("nio create_udp udp-") for c in hypervisor) + assert any(c.startswith('vm slot_add_nio_binding "R1" 0 0 udp-') for c in hypervisor) + ubridge = _ubridge_commands(router) + assert any(c.startswith("bridge create DYNAMIPS-") for c in ubridge) + assert any(c.startswith("bridge add_nio_udp DYNAMIPS-") for c in ubridge) + assert any(c.startswith("bridge start DYNAMIPS-") for c in ubridge) + + +@pytest.mark.asyncio +async def test_add_nio_binding_kernel_binds_and_enslaves(router): + """ + The route-level add path dispatches a NIOBridge to the kernel wiring and + stores it in the slot adapter like any NIO. + """ + + router.status = "started" + router._kernel_taps[(0, 0)] = TAP00 + nio = await _nio(router) + + await router.slot_add_nio_binding(0, 0, nio) + + assert router._slots[0].get_nio(0) is nio + router._ubridge_send.assert_any_call(f'brctl addif "{BRIDGE}" "{TAP00}"') + + +@pytest.mark.asyncio +async def test_update_nio_binding_kernel_reapplies_on_the_anchor(router): + """ + A filter update on an attached kernel link reconciles on the anchor + (tc) without re-binding the port or re-enslaving the TAP. + """ + + router._kernel_taps[(0, 0)] = TAP00 + nio = await _nio(router, filters={"delay": [10]}) + router._slots[0].add_nio(0, nio) + + await router.slot_update_nio_binding(0, 0, nio) + + router._ubridge_send.assert_any_call(f'tc reset "{TAP00}"') + router._ubridge_send.assert_any_call(f'tc netem set "{TAP00}" delay 10') + assert not any("slot_add_nio_binding" in c for c in _hypervisor_commands(router)) + assert not any("brctl addif" in c for c in _ubridge_commands(router)) + + +@pytest.mark.asyncio +async def test_update_nio_binding_kernel_suspend_flaps_the_anchor(router): + """ + Suspending a kernel link admin-downs the anchor — the native carrier, + which is why the route must copy suspend onto the NIO. + """ + + router._kernel_taps[(0, 0)] = TAP00 + nio = await _nio(router) + router._slots[0].add_nio(0, nio) + nio.suspend = True + + await router.slot_update_nio_binding(0, 0, nio) + + router._ubridge_send.assert_any_call(f'link set "{TAP00}" down') + + +@pytest.mark.asyncio +async def test_remove_nio_binding_kernel_releases_the_fd_and_detaches(router): + """ + Removing a kernel link detaches the anchor from the per-link bridge, + resets its qdisc, and unbinds the port plus deletes the hypervisor TAP + NIO — releasing Dynamips's fd while the control channels live. + """ + + router.status = "started" + router._kernel_taps[(0, 0)] = TAP00 + nio = await _nio(router) + router._slots[0].add_nio(0, nio) + await router._attach_kernel_nio(0, 0, nio) + router._ubridge_send.reset_mock() + router._hypervisor.commands.clear() + + removed = await router.slot_remove_nio_binding(0, 0) + + assert removed is nio + assert router._slots[0].get_nio(0) is None + router._ubridge_send.assert_any_call(f'tc reset "{TAP00}"') + router._ubridge_send.assert_any_call(f'brctl delif "{BRIDGE}" "{TAP00}"') + router._ubridge_send.assert_any_call(f'brctl delete "{BRIDGE}"') + hypervisor = _hypervisor_commands(router) + assert any(c.startswith('vm slot_remove_nio_binding "R1" 0 0') for c in hypervisor) + assert any(c.startswith("nio delete tap-") for c in hypervisor) + assert (0, 0) not in router._tap_nios + + +# --------------------------------------------------------------------------- +# Stop order (the hypervisor holds the anchor fds — QEMU's order) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_stop_ubridge_releases_hypervisor_fds_before_deleting_taps(router): + """ + The hypervisor's TAP NIOs are unbound and deleted first (closing every + anchor fd), then the TAPs are unpersisted, then the per-link bridges go — + all before uBridge itself stops. + """ + + router.status = "started" + router._kernel_taps[(0, 0)] = TAP00 + nio = await _nio(router) + router._slots[0].add_nio(0, nio) + await router._attach_kernel_nio(0, 0, nio) + router._ubridge_send.reset_mock() + router._hypervisor.commands.clear() + + with patch( + "gns3server.compute.base_node.BaseNode._stop_ubridge", new=AsyncioMagicMock() + ) as stop: + await router._stop_ubridge() + + hypervisor = _hypervisor_commands(router) + ubridge = _ubridge_commands(router) + unbind = next(i for i, c in enumerate(hypervisor) if c.startswith('vm slot_remove_nio_binding "R1" 0 0')) + nio_delete = next(i for i, c in enumerate(hypervisor) if c.startswith("nio delete tap-")) + tap_delete = next(i for i, c in enumerate(ubridge) if c == f'tap delete "{TAP00}"') + brctl_delete = next(i for i, c in enumerate(ubridge) if c == f'brctl delete "{BRIDGE}"') + assert unbind < nio_delete + assert tap_delete < brctl_delete + assert stop.called + assert router._kernel_taps == {} + assert router._tap_nios == {} + assert router._ubridge_tc_caps is None + + +@pytest.mark.asyncio +async def test_remove_kernel_bridges_covers_wic_ports(router): + """ + The bridge sweep walks adapters derived from the anchor map, so a kernel + link on a WIC-1ENET port (inside a non-Ethernet motherboard) is found. + """ + + router._kernel_taps[(0, 16)] = TAP016 + wic_nio = await _nio(router) + router._slots[0].add_nio(16, wic_nio) + + await router._remove_kernel_bridges() + + router._ubridge_send.assert_any_call(f'brctl delete "{BRIDGE}"') + + +@pytest.mark.asyncio +async def test_slot_add_binding_hot_adds_anchors(router): + """ + An adapter inserted while the router runs (OIR) gets its anchors created + at once, so a kernel link can attach without a restart. + """ + + router.status = "started" + router._tap_datapath = True + router._kernel_taps[(0, 0)] = TAP00 + router._slots[1] = None # free the serial slot for the hot-insert + + await router.slot_add_binding(1, PA_FE_TX()) + + assert router._kernel_taps[(1, 0)] == "gd00010203e1p0" + + +# --------------------------------------------------------------------------- +# Capture (kernel: the anchor; relay: the Dynamips NIO's own pcap) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_capture_on_a_kernel_link_uses_the_anchor(router): + router._kernel_taps[(0, 0)] = TAP00 + nio = await _nio(router) + router._slots[0].add_nio(0, nio) + + await router.start_capture(0, 0, "/tmp/test.pcap") + router._ubridge_send.assert_any_call(f'capture start_kernel {TAP00} "/tmp/test.pcap"') + assert not any("bind_filter" in c for c in _hypervisor_commands(router)) + + await router.stop_capture(0, 0) + router._ubridge_send.assert_any_call("capture stop_kernel") + + +@pytest.mark.asyncio +async def test_capture_on_a_relay_link_uses_the_dynamips_nio(router): + nio = await router.manager.create_nio( + router, {"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001} + ) + router._slots[0].add_nio(0, nio) + + await router.start_capture(0, 0, "/tmp/test.pcap") + assert not any("capture start_kernel" in c for c in _ubridge_commands(router)) + hypervisor = _hypervisor_commands(router) + assert any(c.startswith("nio bind_filter udp-") and c.endswith(" capture") for c in hypervisor) + assert any(c.startswith("nio setup_filter udp-") for c in hypervisor) + + +# --------------------------------------------------------------------------- +# Markers (the polymorphic anchor: relay bridge name on relay, TAP on kernel) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_markers_on_a_kernel_link_attach_to_the_anchor(router): + """ + The marker reconcile is datapath-agnostic: with the anchor as the + location, the attach/toggle/delete primitives translate to + marker *_kernel on the TAP. + """ + + router._kernel_taps[(0, 0)] = TAP00 + nio = await _nio(router) + nio.markers = { + "m1": {"bpf": "vlan 100", "link_id": "lk1", "enabled": True, "data_link_type": "DLT_EN10MB"}, + } + router._slots[0].add_nio(0, nio) + + await router._ubridge_apply_markers(TAP00, nio) + + router._ubridge_send.assert_any_call( + f'marker add_kernel m1 {TAP00} "vlan 100" link lk1 pcap "{router.project.markers_working_directory()}/' + f'{router.id}_lk1_m1.pcap"' + ) + assert router._marker_filter_bridges[("m1", "lk1")] == TAP00 + + await router._ubridge_set_marker_filter_state("m1", False) + router._ubridge_send.assert_any_call(f"marker enable_kernel {TAP00} m1 off") + + await router.delete_marker_capture("m1", "lk1", nio) + router._ubridge_send.assert_any_call(f"marker delete_kernel {TAP00} m1") + + +@pytest.mark.asyncio +async def test_markers_on_a_relay_link_keep_the_ubridge_shape(router): + """ + On the relay datapath the same reconcile talks to the uBridge relay + bridge behind the Dynamips UDP tunnel — unchanged behaviour. + """ + + nio = await router.manager.create_nio( + router, {"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001} + ) + nio.markers = { + "m1": {"bpf": "vlan 100", "link_id": "lk1", "enabled": True, "data_link_type": "DLT_EN10MB"}, + } + router._slots[0].add_nio(0, nio) + tunnel_bridge = "DYNAMIPS-20000-20001" + + await router._ubridge_apply_markers(tunnel_bridge, nio) + + router._ubridge_send.assert_any_call( + f'bridge add_packet_filter {tunnel_bridge} m1 mark "vlan 100" link lk1 pcap ' + f'"{router.project.markers_working_directory()}/{router.id}_lk1_m1.pcap"' + ) + + +# --------------------------------------------------------------------------- +# Manager NIO construction +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_manager_create_nio_builds_the_bridge_nio(router): + """ + The Dynamips manager accepts nio_bridge settings (the kernel-datapath + NIO the controller POSTs) — without it the route 422s before the router + ever sees the link. + """ + + nio = await router.manager.create_nio( + router, {"type": "nio_bridge", "bridge": BRIDGE, "filters": {"delay": [5]}, "suspend": True} + ) + assert isinstance(nio, NIOBridge) + assert nio.bridge == BRIDGE + assert nio.filters == {"delay": [5]} + assert nio.suspend is True + assert _hypervisor_commands(router) == []