feat(dynamips): anchor every Ethernet port on a persistent TAP

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.
This commit is contained in:
YueGuobin 2026-10-01 11:55:04 +08:00
parent 6633304e97
commit 7201a870da
No known key found for this signature in database
5 changed files with 1019 additions and 13 deletions

View File

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

View File

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

View File

@ -15,6 +15,37 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# 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.

View File

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

View File

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