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) == []