From fd4ac4460d00eabb87b900883165496d8dfdce39 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 18 Jul 2026 00:21:19 +0800 Subject: [PATCH 1/4] feat: migrate builtin EthernetSwitch from Dynamips ethsw to ubridge brctl Replace the builtin EthernetSwitch stub with a Linux kernel bridge backed by uBridge's brctl module. Each switch node creates one kernel bridge (gns3br{N}) with VLAN filtering; each port is a persistent TAP enslaved to the bridge, relayed by a per-port uBridge bridge (nio_tap <-> nio_udp). - Access/dot1q/qinq port modes translated to brctl vlan primitives - Compute router repointed from Dynamips to Builtin manager - Tests updated: 21 router-level tests + 215 surrounding tests pass - Real-kernel e2e verified: access 100 PVID untagged, dot1q trunk VIDs 1-4094 + native 1, qinq 802.1ad proto + 200 PVID Ethertype 0x9100/0x9200 handling and port-count guard relaxation are deferred pending resolution. --- .../routes/compute/ethernet_switch_nodes.py | 34 +- .../compute/builtin/nodes/ethernet_switch.py | 464 +++++++++++++++++- .../compute/test_ethernet_switch_nodes.py | 408 +++++++-------- 3 files changed, 652 insertions(+), 254 deletions(-) diff --git a/gns3server/api/routes/compute/ethernet_switch_nodes.py b/gns3server/api/routes/compute/ethernet_switch_nodes.py index a8f755047..eaff681cb 100644 --- a/gns3server/api/routes/compute/ethernet_switch_nodes.py +++ b/gns3server/api/routes/compute/ethernet_switch_nodes.py @@ -16,6 +16,10 @@ """ API routes for Ethernet switch nodes. + +The Ethernet switch is a builtin node backed by a Linux kernel bridge driven +through uBridge's ``brctl`` module (see +``gns3server.compute.builtin.nodes.ethernet_switch``). """ import os @@ -25,8 +29,8 @@ from fastapi.encoders import jsonable_encoder from fastapi.responses import StreamingResponse from uuid import UUID -from gns3server.compute.dynamips import Dynamips -from gns3server.compute.dynamips.nodes.ethernet_switch import EthernetSwitch +from gns3server.compute.builtin import Builtin +from gns3server.compute.builtin.nodes.ethernet_switch import EthernetSwitch from gns3server import schemas responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Ethernet switch node"}} @@ -39,8 +43,8 @@ def dep_node(project_id: UUID, node_id: UUID) -> EthernetSwitch: Dependency to retrieve a node. """ - dynamips_manager = Dynamips.instance() - node = dynamips_manager.get_node(str(node_id), project_id=str(project_id)) + builtin_manager = Builtin.instance() + node = builtin_manager.get_node(str(node_id), project_id=str(project_id)) return node @@ -55,10 +59,9 @@ async def create_ethernet_switch(project_id: UUID, node_data: schemas.EthernetSw Create a new Ethernet switch. """ - # Use the Dynamips Ethernet switch to simulate this node - dynamips_manager = Dynamips.instance() + builtin_manager = Builtin.instance() node_data = jsonable_encoder(node_data, exclude_unset=True) - node = await dynamips_manager.create_node( + node = await builtin_manager.create_node( node_data.pop("name"), str(project_id), node_data.get("node_id"), @@ -67,7 +70,7 @@ async def create_ethernet_switch(project_id: UUID, node_data: schemas.EthernetSw node_type="ethernet_switch", ports=node_data.get("ports_mapping"), ) - + node.usage = node_data.get("usage", "") return node.asdict() @@ -86,7 +89,7 @@ async def duplicate_ethernet_switch( Duplicate an Ethernet switch. """ - new_node = await Dynamips.instance().duplicate_node(node.id, str(destination_node_id)) + new_node = await Builtin.instance().duplicate_node(node.id, str(destination_node_id)) return new_node.asdict() @@ -101,7 +104,9 @@ async def update_ethernet_switch( node_data = jsonable_encoder(node_data, exclude_unset=True) if "name" in node_data and node.name != node_data["name"]: - await node.set_name(node_data["name"]) + node.name = node_data["name"] + if "usage" in node_data: + node.usage = node_data["usage"] if "ports_mapping" in node_data: node.ports_mapping = node_data["ports_mapping"] await node.update_port_settings() @@ -117,7 +122,7 @@ async def delete_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> No Delete an Ethernet switch. """ - await Dynamips.instance().delete_node(node.id) + await Builtin.instance().delete_node(node.id) @router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT) @@ -182,7 +187,7 @@ async def create_ethernet_switch_nio( node: EthernetSwitch = Depends(dep_node) ) -> schemas.UDPNIO: - nio = await Dynamips.instance().create_nio(node, jsonable_encoder(nio_data, exclude_unset=True)) + nio = Builtin.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True)) await node.add_nio(nio, port_number) return nio.asdict() @@ -199,8 +204,7 @@ async def delete_ethernet_switch_nio( The adapter number on the switch is always 0. """ - nio = await node.remove_nio(port_number) - await nio.delete() + await node.remove_nio(port_number) @router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start") @@ -251,5 +255,5 @@ async def stream_pcap_file( """ nio = node.get_nio(port_number) - stream = Dynamips.instance().stream_pcap_file(nio, node.project.id) + stream = Builtin.instance().stream_pcap_file(nio, node.project.id) return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap") diff --git a/gns3server/compute/builtin/nodes/ethernet_switch.py b/gns3server/compute/builtin/nodes/ethernet_switch.py index d4a4863a3..3f1fd263a 100644 --- a/gns3server/compute/builtin/nodes/ethernet_switch.py +++ b/gns3server/compute/builtin/nodes/ethernet_switch.py @@ -14,14 +14,47 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import asyncio +""" +Ethernet switch backed by a Linux kernel bridge driven through uBridge's +``brctl`` module. + +The historical GNS3 Ethernet switch was an emulated L2 device inside Dynamips +(``ethsw``). This implementation replaces it with a *real* Linux kernel bridge: +one bridge per switch node, managed over uBridge's hypervisor socket. Each +switch port is a persistent TAP that plays two roles at once -- uBridge holds +its file descriptor as a ``nio_tap`` relay endpoint, and the same TAP is +enslaved to the kernel bridge as a port. This dual-role TAP is exactly the +pattern the Cloud node already uses for host bridges (see +``cloud.py::_add_linux_ethernet``). + +Data path (UDP link mode):: + + peer --UDP-- ubridge[nio_udp <-> nio_tap(tap)] --tap-- kernel bridge --tap-- ... (other ports) + +The kernel bridge performs MAC learning/forwarding and VLAN filtering; uBridge +is only the per-port UDP transport (uBridge is strictly a 2-NIO pipe, it cannot +be the switch). ESW ``access``/``dot1q``/``qinq`` port modes are composed from +the ``brctl`` VLAN primitives here -- see ``_apply_port_vlan``. +""" + +import psutil from ...base_node import BaseNode +from ...nios.nio_udp import NIOUDP +from ...error import NodeError +from gns3server.compute.ubridge.ubridge_error import UbridgeError import logging log = logging.getLogger(__name__) +# VLAN ethertypes the Linux kernel bridge can realise. ``brctl setvlanproto`` +# accepts only 0x8100 (802.1Q) and 0x88a8 (802.1ad). The GNS3 schema also allows +# the legacy 0x9100/0x9200 QinQ ethertypes; the kernel bridge cannot do those, so +# configuring them on a qinq port is rejected. +_SUPPORTED_VLAN_ETHERTYPE = {"0x8100", "0x88a8"} +_QINQ_ETHERTYPE = "0x88a8" + class EthernetSwitch(BaseNode): @@ -32,11 +65,111 @@ class EthernetSwitch(BaseNode): :param node_id: Node identifier :param project: Project instance :param manager: Parent VM Manager + :param ports: initial switch ports """ - def __init__(self, name, node_id, project, manager): + def __init__(self, name, node_id, project, manager, console=None, console_type=None, ports=None): - super().__init__(name, node_id, project, manager) + super().__init__(name, node_id, project, manager, console=console, console_type=console_type or "none") + # The switch has no console; ``console_type="none"`` makes BaseNode skip + # reserving a TCP console port entirely. + self._ubridge_require_privileged_access = True + + self._nios = {} + self._tap_by_port = {} # port_number -> kernel TAP enslaved to the bridge + self._bridge_name = None # kernel bridge interface name (allocated on start) + self._bridge_created = False + self._bridge_proto_set = False # whether ``brctl setvlanproto`` has been applied + # Idempotency flag for start(). Decoupled from ``status`` so the node can + # report "started" (always-on, like the ESW) while ``duplicate_node`` still + # sees status "stopped" and refuses only genuinely running stateful nodes. + self._started = False + + if ports is None: + # 8 access ports in VLAN 1 by default, matching the historical ESW. + self._ports_mapping = [] + for port_number in range(0, 8): + self._ports_mapping.append( + {"port_number": port_number, "name": f"Ethernet{port_number}", "type": "access", "vlan": 1} + ) + else: + self._ports_mapping = self._normalize_ports(ports) + + # ------------------------------------------------------------------ # + # helpers + # ------------------------------------------------------------------ # + + @staticmethod + def _normalize_ports(ports): + """Assign sequential port numbers/names like the Dynamips ESW did.""" + port_number = 0 + normalized = [] + for port in ports: + port = dict(port) + port["name"] = f"Ethernet{port_number}" + port["port_number"] = port_number + normalized.append(port) + port_number += 1 + return normalized + + def _ubridge_bridge_name(self, port_number): + """Name of the per-port uBridge relay bridge (not a kernel interface).""" + return f"{self._id}-{port_number}" + + @staticmethod + def _free_iface(prefix): + """First free kernel interface name ``prefix`` (kernel names are <=15 chars).""" + existing = psutil.net_if_addrs() + for i in range(4096): + name = f"{prefix}{i}" + if name not in existing and len(name) <= 15: + return name + raise NodeError(f"Could not allocate a free interface name with prefix '{prefix}'") + + def _tap_name(self, port_number): + """Kernel TAP name for a port: ``-`` (host-unique via the bridge).""" + return f"{self._bridge_name}-{port_number}" + + def _port_settings(self, port_number): + for port in self._ports_mapping: + if port["port_number"] == port_number: + return port + return None + + # ------------------------------------------------------------------ # + # properties / serialisation + # ------------------------------------------------------------------ # + + @property + def nios(self): + return self._nios + + @property + def ports_mapping(self): + return self._ports_mapping + + @ports_mapping.setter + def ports_mapping(self, ports): + if ports != self._ports_mapping: + if len(self._nios) > 0 and len(ports) != len(self._ports_mapping): + raise NodeError("Cannot change the port count of a switch that is already connected.") + self._ports_mapping = self._normalize_ports(ports) + + @property + def console(self): + return self._console + + @console.setter + def console(self, console): + self._console = console + + @property + def console_type(self): + return self._console_type + + @console_type.setter + def console_type(self, console_type): + self._console_type = console_type def asdict(self): @@ -44,61 +177,362 @@ class EthernetSwitch(BaseNode): "name": self.name, "usage": self.usage, "node_id": self.id, - "project_id": self.project.id + "project_id": self.project.id, + "ports_mapping": self._ports_mapping, + "console": self.console, + "console_type": self.console_type, + # The switch is always-on once created (a kernel bridge), like the ESW. + "status": "started", } + # ------------------------------------------------------------------ # + # lifecycle + # ------------------------------------------------------------------ # + async def create(self): """ Creates this switch. """ - super().create() + await self.start() log.info(f'Ethernet switch "{self._name}" [{self._id}] has been created') + async def start(self): + """ + Starts this switch: bring up uBridge, create the kernel bridge, and + re-wire any ports already bound before a restart. + """ + + if not self._started: + if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running(): + await self._stop_ubridge() + await self._start_ubridge(self._ubridge_require_privileged_access) + await self._ensure_bridge() + for port_number in self._nios: + if self._nios[port_number]: + try: + await self._add_ubridge_connection(self._nios[port_number], port_number) + except (UbridgeError, NodeError) as e: + self._started = False + raise e + self._started = True + + async def _ensure_bridge(self): + """ + Creates the per-node kernel bridge once and enables VLAN filtering. + Applies the bridge-level QinQ ethertype if any port needs it. + """ + + if self._bridge_created: + return + self._bridge_name = self._free_iface("gns3br") + await self._ubridge_send(f'brctl create "{self._bridge_name}"') + await self._ubridge_send(f'brctl vlanfiltering "{self._bridge_name}" on') + self._bridge_created = True + await self._apply_bridge_proto_if_needed() + + async def _apply_bridge_proto_if_needed(self): + """ + If any port is a QinQ port using the 802.1ad ethertype (0x88a8), switch + the whole bridge to that protocol. A Linux bridge has a single VLAN + protocol, so mixed QinQ ethertypes within one switch are not supported. + """ + + proto = None + for port in self._ports_mapping: + if port.get("type") == "qinq": + # normalise case: the schema carries uppercase (e.g. "0x88A8") but + # brctl setvlanproto wants lowercase hex + ethertype = port.get("ethertype", "0x8100").lower() + if ethertype not in _SUPPORTED_VLAN_ETHERTYPE: + raise NodeError( + f"VLAN ethertype {ethertype} is not supported by the Linux bridge " + f"(only 0x8100/0x88a8) for QinQ port {port['name']}" + ) + if ethertype == _QINQ_ETHERTYPE: + proto = _QINQ_ETHERTYPE + if proto and not self._bridge_proto_set: + await self._ubridge_send(f'brctl setvlanproto "{self._bridge_name}" {proto}') + self._bridge_proto_set = True + async def delete(self): """ Deletes this switch. """ - raise NotImplementedError() + return await self.close() + + async def close(self): + """ + Closes this switch: release UDP ports, tear down the kernel bridge, stop uBridge. + """ + + if not (await super().close()): + return False + + for nio in self._nios.values(): + if nio and isinstance(nio, NIOUDP): + self.manager.port_manager.release_udp_port(nio.lport, self._project) + self._nios.clear() + self._tap_by_port.clear() + + if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running() and self._bridge_created: + try: + # Deleting the bridge releases its enslaved TAPs; uBridge destroys + # them when it stops below. + await self._ubridge_send(f'brctl delete "{self._bridge_name}"') + except UbridgeError as e: + log.warning(f'Could not delete kernel bridge "{self._bridge_name}": {e}') + self._bridge_created = False + self._bridge_proto_set = False + self._bridge_name = None + self._started = False + + await self._stop_ubridge() + log.info(f'Ethernet switch "{self._name}" [{self._id}] has been closed') + return True + + # ------------------------------------------------------------------ # + # per-port wiring + # ------------------------------------------------------------------ # async def add_nio(self, nio, port_number): """ - Adds a NIO as new port on this switch. + Adds a NIO as a new port on this switch. :param nio: NIO instance to add :param port_number: port to allocate for the NIO """ - raise NotImplementedError() + if port_number in self._nios: + raise NodeError(f"Port {port_number} isn't free") + if not isinstance(nio, NIOUDP): + raise NodeError("Ethernet switch ports only support UDP NIOs") + + log.info( + 'Ethernet switch "{name}" [{id}]: NIO {nio} bound to port {port}'.format( + name=self._name, id=self._id, nio=nio, port=port_number + ) + ) + try: + await self.start() + await self._add_ubridge_connection(nio, port_number) + self._nios[port_number] = nio + except (NodeError, UbridgeError) as e: + log.error('Cannot add NIO on Ethernet switch "{name}": {error}'.format(name=self._name, error=e)) + await self._stop_ubridge() + self.status = "stopped" + self._nios[port_number] = nio + self.project.emit("log.error", {"message": str(e)}) + + async def _add_ubridge_connection(self, nio, port_number): + """ + Wires one port: a per-port uBridge relay (nio_tap <-> nio_udp) whose TAP + is enslaved to the kernel bridge, with the port's VLAN mode applied. + """ + + port_settings = self._port_settings(port_number) + if port_settings is None: + raise NodeError(f"Port {port_number} doesn't exist on Ethernet switch '{self.name}'") + + ubridge_bridge = self._ubridge_bridge_name(port_number) + tap = self._tap_name(port_number) + + # per-port uBridge relay -- uBridge holds the TAP fd + await self._ubridge_send(f"bridge create {ubridge_bridge}") + await self._ubridge_send(f'bridge add_nio_tap {ubridge_bridge} "{tap}"') + # enslave the same TAP to the kernel bridge (the cloud.py::_add_linux_ethernet move) + await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{tap}"') + # VLAN membership for this port's access/trunk/qinq mode + await self._apply_port_vlan(port_settings, tap) + # GNS3 link endpoint + await self._ubridge_send( + "bridge add_nio_udp {name} {lport} {rhost} {rport}".format( + name=ubridge_bridge, lport=nio.lport, rhost=nio.rhost, rport=nio.rport + ) + ) + await self._ubridge_apply_filters(ubridge_bridge, nio.filters) + await self._ubridge_apply_markers(ubridge_bridge, nio) + if nio.capturing: + await self._ubridge_send( + 'bridge start_capture {name} "{output_file}"'.format( + name=ubridge_bridge, output_file=nio.pcap_output_file + ) + ) + await self._ubridge_send(f"bridge start {ubridge_bridge}") + self._tap_by_port[port_number] = tap + + async def _delete_ubridge_connection(self, port_number): + """ + Tears down one port's wiring: release the TAP from the bridge and delete + the per-port uBridge relay. + """ + + tap = self._tap_by_port.pop(port_number, None) + ubridge_bridge = self._ubridge_bridge_name(port_number) + if tap is not None and self._bridge_created: + try: + await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"') + except UbridgeError as e: + log.warning(f'Could not remove TAP "{tap}" from bridge "{self._bridge_name}": {e}') + try: + await self._ubridge_send(f"bridge delete {ubridge_bridge}") + except UbridgeError as e: + log.warning(f"Could not delete uBridge bridge {ubridge_bridge}: {e}") async def remove_nio(self, port_number): """ - Removes the specified NIO as member of this switch. + Removes the specified NIO from this switch. :param port_number: allocated port number - :returns: the NIO that was bound to the allocated port """ - raise NotImplementedError() + if port_number not in self._nios: + raise NodeError(f"Port {port_number} is not allocated") + + await self.stop_capture(port_number) + nio = self._nios[port_number] + if isinstance(nio, NIOUDP): + self.manager.port_manager.release_udp_port(nio.lport, self._project) + + log.info( + 'Ethernet switch "{name}" [{id}]: NIO {nio} removed from port {port}'.format( + name=self._name, id=self._id, nio=nio, port=port_number + ) + ) + del self._nios[port_number] + if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running(): + await self._delete_ubridge_connection(port_number) + return nio + + def get_nio(self, port_number): + """ + Gets a port NIO binding. + + :param port_number: port number + :returns: NIO instance + """ + + if port_number not in self._nios: + raise NodeError(f"Port {port_number} is not connected") + return self._nios[port_number] + + async def update_nio(self, port_number, nio): + """ + Re-applies uBridge filters/markers for a port (called when a link is updated). + """ + + ubridge_bridge = self._ubridge_bridge_name(port_number) + if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running(): + await self._ubridge_apply_filters(ubridge_bridge, nio.filters) + await self._ubridge_apply_markers(ubridge_bridge, nio) + + # ------------------------------------------------------------------ # + # VLAN mode translation + # ------------------------------------------------------------------ # + + async def _reset_port_vlan(self, tap): + """ + Resets a port's VLAN membership to the kernel default (PVID 1, untagged) + by re-enslaving it. Used before re-applying a changed mode so stale VIDs + from the previous mode do not leak. + """ + + await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"') + await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{tap}"') + + async def _apply_port_vlan(self, port_settings, tap): + """ + Translates an ESW port mode into ``brctl`` VLAN primitives. The port must + already be enslaved to the bridge and carry the default PVID 1. + + - access VLAN N: drop default 1, add N as PVID + egress untagged. + - dot1q trunk (native N): drop default 1, admit all VIDs tagged, then mark + the native VLAN PVID + untagged. (The ESW model declares only the native + VLAN per trunk port, so the trunk admits all VIDs, like the emulated ESW.) + - qinq (outer N): the bridge-level protocol is set separately; the port + gets the service VLAN as PVID + untagged so customer frames are S-tagged. + """ + + br = self._bridge_name + port_type = port_settings["type"] + vlan = int(port_settings["vlan"]) + + if port_type == "access": + await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1') + await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged') + elif port_type == "dot1q": + await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1') + await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" 1 vid 4094') + await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged') + elif port_type == "qinq": + # setvlanproto is applied at the bridge level by _apply_bridge_proto_if_needed + await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1') + await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged') + else: + raise NodeError(f"Unknown port type '{port_type}' on Ethernet switch '{self.name}'") + + async def update_port_settings(self): + """ + Re-applies port settings (called after ``ports_mapping`` is updated). For + ports already wired, reset then re-apply so a mode/VLAN change fully + replaces the previous VLAN membership. + """ + + await self._apply_bridge_proto_if_needed() + if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running() and self._bridge_created): + return + for port_settings in self._ports_mapping: + port_number = port_settings["port_number"] + tap = self._tap_by_port.get(port_number) + if tap is None: + continue + await self._reset_port_vlan(tap) + await self._apply_port_vlan(port_settings, tap) + + # ------------------------------------------------------------------ # + # capture + # ------------------------------------------------------------------ # async def start_capture(self, port_number, output_file, data_link_type="DLT_EN10MB"): """ - Starts a packet capture. + Starts a packet capture on a port (uBridge captures on the per-port relay). :param port_number: allocated port number :param output_file: PCAP destination file for the capture :param data_link_type: PCAP data link type (DLT_*), default is DLT_EN10MB """ - raise NotImplementedError() + nio = self.get_nio(port_number) + if nio.capturing: + raise NodeError(f"Packet capture is already activated on port {port_number}") + nio.start_packet_capture(output_file) + if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running(): + ubridge_bridge = self._ubridge_bridge_name(port_number) + await self._ubridge_send(f'bridge start_capture {ubridge_bridge} "{output_file}"') + log.info( + 'Ethernet switch "{name}" [{id}]: starting packet capture on port {port}'.format( + name=self.name, id=self.id, port=port_number + ) + ) async def stop_capture(self, port_number): """ - Stops a packet capture. + Stops a packet capture on a port. :param port_number: allocated port number """ - raise NotImplementedError() + nio = self.get_nio(port_number) + if not nio.capturing: + return + nio.stop_packet_capture() + if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running(): + ubridge_bridge = self._ubridge_bridge_name(port_number) + await self._ubridge_send(f"bridge stop_capture {ubridge_bridge}") + log.info( + 'Ethernet switch "{name}" [{id}]: stopping packet capture on port {port}'.format( + name=self.name, id=self.id, port=port_number + ) + ) diff --git a/tests/api/routes/compute/test_ethernet_switch_nodes.py b/tests/api/routes/compute/test_ethernet_switch_nodes.py index a33c13112..52154c2ae 100644 --- a/tests/api/routes/compute/test_ethernet_switch_nodes.py +++ b/tests/api/routes/compute/test_ethernet_switch_nodes.py @@ -21,34 +21,48 @@ import pytest_asyncio from fastapi import FastAPI, status from httpx import AsyncClient from tests.utils import asyncio_patch, AsyncioMagicMock -from unittest.mock import call +from unittest.mock import call, MagicMock from gns3server.compute.project import Project +# The builtin Ethernet switch talks to uBridge (brctl/bridge modules) instead of +# the Dynamips hypervisor. These are the seams we stub so the routes can be +# exercised without launching a real uBridge / creating kernel interfaces. +_NODE = "gns3server.compute.builtin.nodes.ethernet_switch.EthernetSwitch" + pytestmark = pytest.mark.asyncio class TestEthernetSwitchNodesRoutes: + @pytest_asyncio.fixture(autouse=True) + async def stub_ubridge(self): + """Keep uBridge from really starting and capture every command.""" + with asyncio_patch(f"{_NODE}._start_ubridge"), asyncio_patch(f"{_NODE}._stop_ubridge"), \ + asyncio_patch(f"{_NODE}._ubridge_send"): + yield + @pytest_asyncio.fixture async def ethernet_switch(self, app: FastAPI, compute_client: AsyncClient, compute_project: Project) -> dict: params = {"name": "Ethernet Switch"} - with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.create") as mock: - response = await compute_client.post( - app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id), - json=params - ) - assert mock.called - assert response.status_code == status.HTTP_201_CREATED + response = await compute_client.post( + app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id), + json=params + ) + assert response.status_code == status.HTTP_201_CREATED json_response = response.json() node = compute_project.get_node(json_response["node_id"]) - node._hypervisor = AsyncioMagicMock() - node._hypervisor.send = AsyncioMagicMock() - node._hypervisor.version = "0.2.16" + # Pretend uBridge is up so the is_running() guards in remove/close pass. + node._ubridge_hypervisor = MagicMock() + node._ubridge_hypervisor.is_running.return_value = True + node._ubridge_send.reset_mock() return json_response + @staticmethod + def _udp_params() -> dict: + return {"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"} async def test_ethernet_switch_create( self, app: FastAPI, @@ -57,16 +71,22 @@ class TestEthernetSwitchNodesRoutes: ) -> None: params = {"name": "Ethernet Switch 1"} - with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.create") as mock: - response = await compute_client.post( - app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id), - json=params - ) - assert mock.called - assert response.status_code == status.HTTP_201_CREATED - assert response.json()["name"] == "Ethernet Switch 1" - assert response.json()["project_id"] == compute_project.id + response = await compute_client.post( + app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id), + json=params + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["name"] == "Ethernet Switch 1" + assert response.json()["project_id"] == compute_project.id + assert response.json()["status"] == "started" + # creation stands up the kernel bridge with VLAN filtering + node = compute_project.get_node(response.json()["node_id"]) + br = node._bridge_name + node._ubridge_send.assert_has_calls([ + call(f'brctl create "{br}"'), + call(f'brctl vlanfiltering "{br}" on'), + ]) async def test_ethernet_switch_get( self, app: FastAPI, @@ -87,7 +107,6 @@ class TestEthernetSwitchNodesRoutes: assert response.json()["project_id"] == compute_project.id assert response.json()["status"] == "started" - async def test_ethernet_switch_duplicate( self, app: FastAPI, @@ -98,15 +117,11 @@ class TestEthernetSwitchNodesRoutes: # create destination switch first params = {"name": "Ethernet Switch 2"} - with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.create") as mock: - response = await compute_client.post( - app.url_path_for( - "compute:create_ethernet_switch", - project_id=compute_project.id), - json=params - ) - assert mock.called - assert response.status_code == status.HTTP_201_CREATED + response = await compute_client.post( + app.url_path_for("compute:create_ethernet_switch", project_id=compute_project.id), + json=params + ) + assert response.status_code == status.HTTP_201_CREATED params = {"destination_node_id": response.json()["node_id"]} response = await compute_client.post( @@ -117,7 +132,6 @@ class TestEthernetSwitchNodesRoutes: ) assert response.status_code == status.HTTP_201_CREATED - async def test_ethernet_switch_update( self, app: FastAPI, @@ -126,10 +140,7 @@ class TestEthernetSwitchNodesRoutes: ethernet_switch: dict ) -> None: - params = { - "name": "test", - "console_type": "telnet" - } + params = {"name": "test", "console_type": "none"} response = await compute_client.put( app.url_path_for( @@ -141,11 +152,12 @@ class TestEthernetSwitchNodesRoutes: assert response.status_code == status.HTTP_200_OK assert response.json()["name"] == "test" + # renaming a builtin switch does not touch uBridge (the kernel bridge is + # name-independent); nothing should have been sent. node = compute_project.get_node(ethernet_switch["node_id"]) - node._hypervisor.send.assert_called_with("ethsw rename \"Ethernet Switch\" \"test\"") + node._ubridge_send.assert_not_called() - - async def test_ethernet_switch_update_ports( + async def test_ethernet_switch_update_ports_qinq_proto( self, app: FastAPI, compute_client: AsyncClient, @@ -153,33 +165,11 @@ class TestEthernetSwitchNodesRoutes: ethernet_switch: dict ) -> None: + # a QinQ port with the 802.1ad ethertype must switch the bridge protocol port_params = { "ports_mapping": [ - { - "name": "Ethernet0", - "port_number": 0, - "type": "qinq", - "vlan": 1 - }, - { - "name": "Ethernet1", - "port_number": 1, - "type": "qinq", - "vlan": 2, - "ethertype": "0x88A8" - }, - { - "name": "Ethernet2", - "port_number": 2, - "type": "dot1q", - "vlan": 3, - }, - { - "name": "Ethernet3", - "port_number": 3, - "type": "access", - "vlan": 4, - } + {"name": "Ethernet0", "port_number": 0, "type": "qinq", "vlan": 2, "ethertype": "0x88A8"}, + {"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 4}, ], } @@ -192,90 +182,20 @@ class TestEthernetSwitchNodesRoutes: ) assert response.status_code == status.HTTP_200_OK - nio_params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1" - } - - for port_mapping in port_params["ports_mapping"]: - port_number = port_mapping["port_number"] - vlan = port_mapping["vlan"] - port_type = port_mapping["type"] - ethertype = port_mapping.get("ethertype", "") - url = app.url_path_for( - "compute:create_ethernet_switch_nio", - project_id=ethernet_switch["project_id"], - node_id=ethernet_switch["node_id"], - adapter_number="0", - port_number=f"{port_number}" - ) - await compute_client.post(url, json=nio_params) - - node = compute_project.get_node(ethernet_switch["node_id"]) - nio = node.get_nio(port_number) - calls = [ - call.send(f'nio create_udp {nio.name} 4242 127.0.0.1 4343'), - call.send(f'ethsw add_nio "Ethernet Switch" {nio.name}'), - call.send(f'ethsw set_{port_type}_port "Ethernet Switch" {nio.name} {vlan} {ethertype}'.strip()) - ] - node._hypervisor.send.assert_has_calls(calls) - node._hypervisor.send.reset_mock() - + node = compute_project.get_node(ethernet_switch["node_id"]) + node._ubridge_send.assert_any_call(f'brctl setvlanproto "{node._bridge_name}" 0x88a8') @pytest.mark.parametrize( "ports_settings", ( - ( - { - "name": "Ethernet0", - "port_number": 0, - "type": "dot42q", # invalid port type - "vlan": 1, - } - ), - ( - { - "name": "Ethernet0", - "port_number": 0, - "type": "access", # missing vlan field - } - ), - ( - { - "name": "Ethernet0", - "port_number": 0, - "type": "dot1q", - "vlan": 1, - "ethertype": "0x88A8" # EtherType is only for QinQ - } - ), - ( - { - "name": "Ethernet0", - "port_number": 0, - "type": "qinq", - "vlan": 1, - "ethertype": "0x4242" # not a valid EtherType - } - ), - ( - { - "name": "Ethernet0", - "port_number": 0, - "type": "access", - "vlan": 0, # minimum vlan number is 1 - } - ), - ( - { - "name": "Ethernet0", - "port_number": 0, - "type": "access", - "vlan": 4242, # maximum vlan number is 4094 - } - ), + {"name": "Ethernet0", "port_number": 0, "type": "dot42q", "vlan": 1}, # bad type + {"name": "Ethernet0", "port_number": 0, "type": "access"}, # missing vlan + {"name": "Ethernet0", "port_number": 0, "type": "dot1q", "vlan": 1, + "ethertype": "0x88A8"}, # ethertype only for qinq + {"name": "Ethernet0", "port_number": 0, "type": "qinq", "vlan": 1, + "ethertype": "0x4242"}, # bad ethertype + {"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 0}, # vlan < 1 + {"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 4242}, # vlan > 4094 ) ) async def test_ethernet_switch_update_ports_invalid( @@ -286,20 +206,15 @@ class TestEthernetSwitchNodesRoutes: ports_settings: dict, ) -> None: - port_params = { - "ports_mapping": [ports_settings] - } - response = await compute_client.put( app.url_path_for( "compute:update_ethernet_switch", project_id=ethernet_switch["project_id"], node_id=ethernet_switch["node_id"]), - json=port_params + json={"ports_mapping": [ports_settings]} ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT - async def test_ethernet_switch_delete( self, app: FastAPI, compute_client: AsyncClient, @@ -315,12 +230,7 @@ class TestEthernetSwitchNodesRoutes: ) assert response.status_code == status.HTTP_204_NO_CONTENT - - async def test_ethernet_switch_start( - self, app: FastAPI, - compute_client: AsyncClient, - ethernet_switch: dict - ) -> None: + async def test_ethernet_switch_start(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None: response = await compute_client.post( app.url_path_for( @@ -331,12 +241,7 @@ class TestEthernetSwitchNodesRoutes: ) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - - async def test_ethernet_switch_stop( - self, app: FastAPI, - compute_client: AsyncClient, - ethernet_switch: dict - ) -> None: + async def test_ethernet_switch_stop(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None: response = await compute_client.post( app.url_path_for( @@ -347,12 +252,7 @@ class TestEthernetSwitchNodesRoutes: ) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - - async def test_ethernet_switch_suspend( - self, app: FastAPI, - compute_client: AsyncClient, - ethernet_switch: dict - ) -> None: + async def test_ethernet_switch_suspend(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None: response = await compute_client.post( app.url_path_for( @@ -363,12 +263,7 @@ class TestEthernetSwitchNodesRoutes: ) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - - async def test_ethernet_switch_reload( - self, app: FastAPI, - compute_client: AsyncClient, - ethernet_switch: dict - ) -> None: + async def test_ethernet_switch_reload(self, app: FastAPI, compute_client: AsyncClient, ethernet_switch: dict) -> None: response = await compute_client.post( app.url_path_for( @@ -379,8 +274,7 @@ class TestEthernetSwitchNodesRoutes: ) assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED - - async def test_ethernet_switch_create_udp( + async def test_ethernet_switch_create_udp_access( self, app: FastAPI, compute_client: AsyncClient, @@ -388,13 +282,6 @@ class TestEthernetSwitchNodesRoutes: ethernet_switch: dict ) -> None: - params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1" - } - url = app.url_path_for( "compute:create_ethernet_switch_nio", project_id=ethernet_switch["project_id"], @@ -402,19 +289,66 @@ class TestEthernetSwitchNodesRoutes: adapter_number="0", port_number="0" ) - response = await compute_client.post(url, json=params) + response = await compute_client.post(url, json=self._udp_params()) assert response.status_code == status.HTTP_201_CREATED assert response.json()["type"] == "nio_udp" node = compute_project.get_node(ethernet_switch["node_id"]) nio = node.get_nio(0) - calls = [ - call.send(f'nio create_udp {nio.name} 4242 127.0.0.1 4343'), - call.send(f'ethsw add_nio "Ethernet Switch" {nio.name}'), - call.send(f'ethsw set_access_port "Ethernet Switch" {nio.name} 1') - ] - node._hypervisor.send.assert_has_calls(calls) + br = node._bridge_name + tap = f"{br}-0" + relay = f"{node.id}-0" + # access VLAN 1 (default): drop default PVID 1, re-add 1 as PVID/untagged + node._ubridge_send.assert_has_calls([ + call(f"bridge create {relay}"), + call(f'bridge add_nio_tap {relay} "{tap}"'), + call(f'brctl addif "{br}" "{tap}"'), + call(f'brctl vlan_del "{br}" "{tap}" 1'), + call(f'brctl vlan_add "{br}" "{tap}" 1 pvid untagged'), + call(f"bridge add_nio_udp {relay} {nio.lport} {nio.rhost} {nio.rport}"), + call(f"bridge reset_packet_filters {relay}"), + call(f"bridge start {relay}"), + ]) + async def test_ethernet_switch_create_udp_dot1q( + self, + app: FastAPI, + compute_client: AsyncClient, + compute_project: Project, + ethernet_switch: dict + ) -> None: + + # make port 0 a dot1q trunk with native VLAN 10 + await compute_client.put( + app.url_path_for( + "compute:update_ethernet_switch", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"]), + json={"ports_mapping": [ + {"name": "Ethernet0", "port_number": 0, "type": "dot1q", "vlan": 10}, + ]} + ) + node = compute_project.get_node(ethernet_switch["node_id"]) + node._ubridge_send.reset_mock() + + url = app.url_path_for( + "compute:create_ethernet_switch_nio", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + adapter_number="0", + port_number="0" + ) + response = await compute_client.post(url, json=self._udp_params()) + assert response.status_code == status.HTTP_201_CREATED + + br = node._bridge_name + tap = f"{br}-0" + # trunk: drop default 1, admit all VIDs tagged, mark native 10 PVID/untagged + node._ubridge_send.assert_has_calls([ + call(f'brctl vlan_del "{br}" "{tap}" 1'), + call(f'brctl vlan_add "{br}" "{tap}" 1 vid 4094'), + call(f'brctl vlan_add "{br}" "{tap}" 10 pvid untagged'), + ]) async def test_ethernet_switch_delete_nio( self, @@ -424,13 +358,6 @@ class TestEthernetSwitchNodesRoutes: ethernet_switch: dict ) -> None: - params = { - "type": "nio_udp", - "lport": 4242, - "rport": 4343, - "rhost": "127.0.0.1" - } - url = app.url_path_for( "compute:create_ethernet_switch_nio", project_id=ethernet_switch["project_id"], @@ -438,11 +365,10 @@ class TestEthernetSwitchNodesRoutes: adapter_number="0", port_number="0" ) - await compute_client.post(url, json=params) + await compute_client.post(url, json=self._udp_params()) node = compute_project.get_node(ethernet_switch["node_id"]) - node._hypervisor.send.reset_mock() - nio = node.get_nio(0) + node._ubridge_send.reset_mock() url = app.url_path_for( "compute:delete_ethernet_switch_nio", @@ -454,52 +380,86 @@ class TestEthernetSwitchNodesRoutes: response = await compute_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT - calls = [ - call(f'ethsw remove_nio "Ethernet Switch" {nio.name}'), - call(f'nio delete {nio.name}') - ] - node._hypervisor.send.assert_has_calls(calls) - + br = node._bridge_name + tap = f"{br}-0" + relay = f"{node.id}-0" + node._ubridge_send.assert_has_calls([ + call(f'brctl delif "{br}" "{tap}"'), + call(f"bridge delete {relay}"), + ]) async def test_ethernet_switch_start_capture( self, app: FastAPI, compute_client: AsyncClient, + compute_project: Project, ethernet_switch: dict ) -> None: - params = { - "capture_file_name": "test.pcap", - "data_link_type": "DLT_EN10MB" - } + # capture needs a wired port + url = app.url_path_for( + "compute:create_ethernet_switch_nio", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + adapter_number="0", + port_number="0" + ) + await compute_client.post(url, json=self._udp_params()) + node = compute_project.get_node(ethernet_switch["node_id"]) + node._ubridge_send.reset_mock() + + params = {"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"} url = app.url_path_for("compute:start_ethernet_switch_capture", project_id=ethernet_switch["project_id"], node_id=ethernet_switch["node_id"], adapter_number="0", port_number="0") - with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.start_capture") as mock: - response = await compute_client.post(url, json=params) - assert response.status_code == status.HTTP_200_OK - assert mock.called - assert "test.pcap" in response.json()["pcap_file_path"] - + response = await compute_client.post(url, json=params) + assert response.status_code == status.HTTP_200_OK + assert "test.pcap" in response.json()["pcap_file_path"] + relay = f"{node.id}-0" + node._ubridge_send.assert_any_call(f'bridge start_capture {relay} "{node.get_nio(0).pcap_output_file}"') async def test_ethernet_switch_stop_capture( self, app: FastAPI, compute_client: AsyncClient, + compute_project: Project, ethernet_switch: dict ) -> None: - url = app.url_path_for("compute:stop_ethernet_switch_capture", - project_id=ethernet_switch["project_id"], - node_id=ethernet_switch["node_id"], - adapter_number="0", - port_number="0") + # start a capture first + await compute_client.post( + app.url_path_for( + "compute:create_ethernet_switch_nio", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + adapter_number="0", + port_number="0" + ), + json=self._udp_params() + ) + await compute_client.post( + app.url_path_for("compute:start_ethernet_switch_capture", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + adapter_number="0", + port_number="0"), + json={"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"} + ) - with asyncio_patch("gns3server.compute.dynamips.nodes.ethernet_switch.EthernetSwitch.stop_capture") as mock: - response = await compute_client.post(url) - assert response.status_code == status.HTTP_204_NO_CONTENT - assert mock.called + node = compute_project.get_node(ethernet_switch["node_id"]) + node._ubridge_send.reset_mock() + relay = f"{node.id}-0" + + response = await compute_client.post( + app.url_path_for("compute:stop_ethernet_switch_capture", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + adapter_number="0", + port_number="0") + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + node._ubridge_send.assert_any_call(f"bridge stop_capture {relay}") From aabbee3a04edd0654283134568e8ca7081d64f19 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 18 Jul 2026 01:11:25 +0800 Subject: [PATCH 2/4] fix(builtin): bring kernel bridge UP after creation brctl create leaves the bridge administratively DOWN. Add link set up so the bridge actually forwards frames between enslaved ports. Also record Docker iptables FORWARD DROP pitfall in project memory. --- .claude/memory/MEMORY.md | 1 + .../memory/docker-iptables-forward-bridge.md | 38 +++++++++++++++++++ .../compute/builtin/nodes/ethernet_switch.py | 2 + .../compute/test_ethernet_switch_nodes.py | 1 + 4 files changed, 42 insertions(+) create mode 100644 .claude/memory/docker-iptables-forward-bridge.md diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 7956d4d04..dbc0f3b8d 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -24,6 +24,7 @@ ### uBridge Permission - **[uBridge Permission Issue](./gns3-ubridge-permission.md)** - Docker containers fail to start due to missing CAP_NET_ADMIN/CAP_NET_RAW capabilities on uBridge +- **[Docker iptables FORWARD blocks bridge](./docker-iptables-forward-bridge.md)** - Docker sets FORWARD chain to DROP, blocking kernel bridge forwarding; `sudo iptables -P FORWARD ACCEPT` to fix ### Docker Container Stop Delay - **[Docker Container Stop Delay](./docker-container-stop-delay.md)** - Some containers take ~5s to stop because they don't handle SIGTERM (AlpiNet, OstinatoWireshark) diff --git a/.claude/memory/docker-iptables-forward-bridge.md b/.claude/memory/docker-iptables-forward-bridge.md new file mode 100644 index 000000000..2716fe7ab --- /dev/null +++ b/.claude/memory/docker-iptables-forward-bridge.md @@ -0,0 +1,38 @@ +--- +name: docker-iptables-forward-bridge +description: Docker iptables FORWARD DROP blocks kernel bridge forwarding, fix and symptoms +metadata: + type: reference +--- + +Docker sets the iptables `FORWARD` chain default policy to `DROP` when the +Docker daemon starts. This blocks **all** forwarded traffic through Linux +kernel bridges on the host — including `gns3br{N}` bridges created by the +builtin Ethernet Switch (ubridge `brctl`). + +## Symptoms + +- Nodes connected to the switch can send frames into the bridge (visible in + `tcpdump -i gns3br{N}`) but never receive forwarded unicast frames. +- `bridge fdb show` may fail to learn MAC addresses (frames dropped before + the bridge learning path). +- ARP and multicast/broadcast may appear to work because they flood, but + unicast replies never reach the destination. +- OSPF Hello / CDP visible on both sides but ICMP echo reply never returns. +- `ubridge bridge get_stats` shows symmetric IN/OUT counts (relay is fine), + `bridge fdb show` shows learned MACs, `bridge link show` shows `state forwarding` + on all ports — yet unicast still doesn't work. + +## Fix + +Run once per host boot, or make persistent via iptables-persistent / firewall config: + +```bash +sudo iptables -P FORWARD ACCEPT +``` + +## Related + +- [[ethernet-switch-ubridge-brctl-migration]] — the kernel bridge that hits this +- [[gns3-server-linux-only]] — datapath constraint +- [[gns3-ubridge-permission]] — another host-level prerequisite (CAP_NET_ADMIN) diff --git a/gns3server/compute/builtin/nodes/ethernet_switch.py b/gns3server/compute/builtin/nodes/ethernet_switch.py index 3f1fd263a..71ab22977 100644 --- a/gns3server/compute/builtin/nodes/ethernet_switch.py +++ b/gns3server/compute/builtin/nodes/ethernet_switch.py @@ -227,6 +227,8 @@ class EthernetSwitch(BaseNode): return self._bridge_name = self._free_iface("gns3br") await self._ubridge_send(f'brctl create "{self._bridge_name}"') + # ``brctl create`` leaves the bridge DOWN; bring it UP so it forwards. + await self._ubridge_send(f'link set "{self._bridge_name}" up') await self._ubridge_send(f'brctl vlanfiltering "{self._bridge_name}" on') self._bridge_created = True await self._apply_bridge_proto_if_needed() diff --git a/tests/api/routes/compute/test_ethernet_switch_nodes.py b/tests/api/routes/compute/test_ethernet_switch_nodes.py index 52154c2ae..40ac32ad0 100644 --- a/tests/api/routes/compute/test_ethernet_switch_nodes.py +++ b/tests/api/routes/compute/test_ethernet_switch_nodes.py @@ -85,6 +85,7 @@ class TestEthernetSwitchNodesRoutes: br = node._bridge_name node._ubridge_send.assert_has_calls([ call(f'brctl create "{br}"'), + call(f'link set "{br}" up'), call(f'brctl vlanfiltering "{br}" on'), ]) From 18a123e0b96a6a71f681ec705ac1f658d5d03175 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 18 Jul 2026 01:18:32 +0800 Subject: [PATCH 3/4] refactor(builtin): use deterministic bridge name from switch UUID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace psutil.net_if_addrs() scan with a deterministic name derived from the switch's node UUID: gns3 + first 6 hex chars (10 chars, fits kernel IFNAMSIZ limit of 15). Taps: - (12-13 chars). Crash recovery: brctl delete the bridge first (best-effort), then create fresh. Stale bridges from abnormal gns3server shutdown are automatically reclaimed on the next start — no EEXIST or leaked interfaces. Remove _free_iface helper and psutil import (no longer needed). --- .../compute/builtin/nodes/ethernet_switch.py | 25 +++++++++---------- .../compute/test_ethernet_switch_nodes.py | 1 + 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/gns3server/compute/builtin/nodes/ethernet_switch.py b/gns3server/compute/builtin/nodes/ethernet_switch.py index 71ab22977..a46366dd0 100644 --- a/gns3server/compute/builtin/nodes/ethernet_switch.py +++ b/gns3server/compute/builtin/nodes/ethernet_switch.py @@ -37,8 +37,6 @@ be the switch). ESW ``access``/``dot1q``/``qinq`` port modes are composed from the ``brctl`` VLAN primitives here -- see ``_apply_port_vlan``. """ -import psutil - from ...base_node import BaseNode from ...nios.nio_udp import NIOUDP from ...error import NodeError @@ -116,16 +114,6 @@ class EthernetSwitch(BaseNode): """Name of the per-port uBridge relay bridge (not a kernel interface).""" return f"{self._id}-{port_number}" - @staticmethod - def _free_iface(prefix): - """First free kernel interface name ``prefix`` (kernel names are <=15 chars).""" - existing = psutil.net_if_addrs() - for i in range(4096): - name = f"{prefix}{i}" - if name not in existing and len(name) <= 15: - return name - raise NodeError(f"Could not allocate a free interface name with prefix '{prefix}'") - def _tap_name(self, port_number): """Kernel TAP name for a port: ``-`` (host-unique via the bridge).""" return f"{self._bridge_name}-{port_number}" @@ -221,11 +209,22 @@ class EthernetSwitch(BaseNode): """ Creates the per-node kernel bridge once and enables VLAN filtering. Applies the bridge-level QinQ ethertype if any port needs it. + + The bridge name is deterministic: ``gns3`` + the first 6 hex chars of + this switch's UUID (kernel interface names are ≤ 15 chars). A stale + bridge from a previous crash is deleted first so ``brctl create`` never + hits EEXIST. """ if self._bridge_created: return - self._bridge_name = self._free_iface("gns3br") + # deterministic short name — 10 chars, always fits the 15-char kernel cap + self._bridge_name = "gns3" + self._id.replace("-", "")[:6] + # crash recovery: best-effort delete any leftover bridge + try: + await self._ubridge_send(f'brctl delete "{self._bridge_name}"') + except UbridgeError: + pass # not found = nothing to clean await self._ubridge_send(f'brctl create "{self._bridge_name}"') # ``brctl create`` leaves the bridge DOWN; bring it UP so it forwards. await self._ubridge_send(f'link set "{self._bridge_name}" up') diff --git a/tests/api/routes/compute/test_ethernet_switch_nodes.py b/tests/api/routes/compute/test_ethernet_switch_nodes.py index 40ac32ad0..16677686b 100644 --- a/tests/api/routes/compute/test_ethernet_switch_nodes.py +++ b/tests/api/routes/compute/test_ethernet_switch_nodes.py @@ -84,6 +84,7 @@ class TestEthernetSwitchNodesRoutes: node = compute_project.get_node(response.json()["node_id"]) br = node._bridge_name node._ubridge_send.assert_has_calls([ + call(f'brctl delete "{br}"'), call(f'brctl create "{br}"'), call(f'link set "{br}" up'), call(f'brctl vlanfiltering "{br}" on'), From 7c320d61000076e355a59cc74586840fdbe8fcdd Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 18 Jul 2026 01:27:53 +0800 Subject: [PATCH 4/4] docs: add builtin Ethernet Switch uBridge brctl implementation document --- .../builtin-ethernet-switch-ubridge.md | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 docs/features/builtin-ethernet-switch-ubridge.md diff --git a/docs/features/builtin-ethernet-switch-ubridge.md b/docs/features/builtin-ethernet-switch-ubridge.md new file mode 100644 index 000000000..14e8e46b2 --- /dev/null +++ b/docs/features/builtin-ethernet-switch-ubridge.md @@ -0,0 +1,262 @@ + + +> This documentation is AI-generated with reference to actual code and verified +> against real-kernel testing (Linux 7.1.2-1-default). AI can make mistakes — +> please verify against the source code when in doubt. + +# Builtin Ethernet Switch — uBridge brctl Backend + +## Overview + +The historical GNS3 Ethernet Switch was an emulated L2 device inside Dynamips +(`ethsw`). This implementation replaces it with a **real Linux kernel bridge** +driven through uBridge's `brctl` module — one bridge per switch node. The +migration makes the switch a first-class builtin node (no Dynamips dependency) +and enables native-kernel-speed L2 switching with VLAN filtering and QinQ. + +| | Old (Dynamips ethsw) | New (uBridge brctl) | +|---|---|---| +| Switching engine | Dynamips user-space emulation | Linux kernel bridge (netlink) | +| VLAN model | ethsw ACL per port | Kernel VLAN filtering + PVID/untagged | +| QinQ | 0x8100/0x88A8/0x9100/0x9200 | 0x8100 (802.1Q) / 0x88A8 (802.1ad) | +| Data path | Node NIO ↔ ethsw NIO (Dynamips) | Node NIO ↔ uBridge relay ↔ TAP ↔ kernel bridge | +| Console | Inactive (reserved TCP port) | None (console_type=none) | +| Node type | `dynamips`-routed | `builtin` (always-on) | + +## Architecture + +``` + ┌───────────┐ ┌──────────────┐ ┌───────────────┐ ┌───────────┐ + │ Peer A │ │ uBridge │ │ Kernel │ │ Peer B │ + │ (Dynamips │◄───►│ per-port │◄───►│ Bridge │◄───►│ (IOU / │ + │ / IOU / │ UDP │ relay │ TAP │ gns3{id[:6]}│ TAP │ QEMU / …) │ + │ QEMU) │ │ nio_tap↔udp │ │ vlan_filter │ │ │ + └───────────┘ └──────────────┘ └──────┬────────┘ └───────────┘ + │ + ┌─────┴─────┐ + │ ... more │ + │ ports │ + └───────────┘ +``` + +Each switch port is a **dual-role TAP** — uBridge holds the file descriptor as a +`nio_tap` relay endpoint, and the same TAP is enslaved to the kernel bridge via +`brctl addif`. This is the same pattern the Cloud node already uses for host +bridges (`cloud.py:_add_linux_ethernet`). uBridge is **only** the per-port UDP +transport; the kernel bridge performs the actual MAC learning, forwarding, and +VLAN filtering. + +### Component map + +| File | Role | +|------|------| +| `compute/builtin/nodes/ethernet_switch.py` | Node implementation | +| `api/routes/compute/ethernet_switch_nodes.py` | REST endpoints (repointed to Builtin) | +| `schemas/compute/ethernet_switch_nodes.py` | Request/response models (unchanged) | +| `controller/udp_link.py` | Link creation — pushes NIO to switch via standard adapter endpoint | + +## Lifecycle + +### `create()` → `start()` + +1. `_start_ubridge(require_privileged_access=True)` — launch uBridge instance +2. `_ensure_bridge()`: + - Derive deterministic bridge name: `gns3` + first 6 hex chars of `self.id` + - `brctl delete` (best-effort — crash recovery, cleans stale interfaces) + - `brctl create` + - `link set … up` (bridge is DOWN after create) + - `brctl vlanfiltering … on` + +### `add_nio(nio, port_number)` + +Per port, one uBridge relay bridge `{node_id}-{port}` is wired: + +``` +bridge create {node_id}-{port} +bridge add_nio_tap {node_id}-{port} "{tap}" ← uBridge holds TAP fd +brctl addif "{bridge}" "{tap}" ← enslave to kernel bridge +brctl vlan_del/vlan_add … ← apply port VLAN mode +bridge add_nio_udp {node_id}-{port} lport rhost rport +bridge reset_packet_filters {node_id}-{port} ← from _ubridge_apply_filters +bridge start {node_id}-{port} +``` + +Captures and marker signals are applied via the existing `_ubridge_apply_filters` +and `_ubridge_apply_markers` helpers from `BaseNode`. + +### `remove_nio(port_number)` + +``` +brctl delif "{bridge}" "{tap}" +bridge delete {node_id}-{port} +release_udp_port(nio.lport) +``` + +### `close()` + +``` +for each port: release UDP port +brctl delete "{self._bridge_name}" ← kernel bridge teardown +_stop_ubridge() ← destroys remaining TAPs +``` + +**Cleanup paths:** + +| Scenario | Bridge cleanup | TAP cleanup | +|----------|---------------|-------------| +| Normal project close | `close()` → `brctl delete` | uBridge stops → TAP fd closed → kernel destroys | +| gns3server crash / kill | Next `_ensure_bridge()` → `brctl delete` before `create` | uBridge dies → TAP fd closed by kernel | +| Manual project-file deletion after crash | Leaked (no GNS3 record of `gns3{id[:6]}`) | Leaked (same — but uBridge probably dead, TAPs gone with it) | + +## Port mode → VLAN translation + +All VLAN operations ride on the `brctl` hypervisor module (`../ubridge/doc/brctl.md`). +The kernel bridge must have `vlan_filtering on` before any `vlan_*` call. + +### access VLAN N + +``` +brctl vlan_del {br} {tap} 1 ← remove default PVID 1 +brctl vlan_add {br} {tap} N pvid untagged +``` + +### dot1q trunk (native VLAN V) + +A dot1q trunk in ESW is "admit all VLANs tagged, native VLAN PVID + untagged": +``` +brctl vlan_del {br} {tap} 1 +brctl vlan_add {br} {tap} 1 vid 4094 ← admit all VIDs tagged +brctl vlan_add {br} {tap} V pvid untagged ← override native +``` + +### qinq (outer VLAN O, ethertype 0x88A8) + +Bridge-level (once): +``` +brctl setvlanproto {br} 0x88a8 ← switch to 802.1ad (outer S-tag) +``` + +Port-level: +``` +brctl vlan_del {br} {tap} 1 +brctl vlan_add {br} {tap} O pvid untagged ← S-tag push for untagged ingress +``` + +Ethertype 0x8100 qinq ports are treated as plain access ports (the bridge +defaults to 0x8100; no `setvlanproto` needed). Ethertype 0x9100/0x9200 are +not supported by the kernel bridge — see § Limitations. + +### Runtime reconfiguration (`update_port_settings`) + +On `ports_mapping` update, existing port VLANs are reset before re-apply: +``` +brctl delif {br} {tap} ← release from bridge (clears VLAN state) +brctl addif {br} {tap} ← re-enslave (resets to default PVID 1) +brctl vlan_del/vlan_add … ← apply new mode +``` + +This prevents stale VLAN membership from a previous mode leaking into the new +configuration (e.g., access→trunk transition leaving old access VLAN behind). + +## Bridge naming + +Deterministic from the switch's UUID: `gns3` + first 6 hex chars (no dashes). + +``` +gns3a1b2c3 ← bridge (10 chars, ≤ 15 IFNAMSIZ limit) +gns3a1b2c3-0 ← tap for port 0 (12 chars) +gns3a1b2c3-1 ← tap for port 1 (12 chars) +``` + +- 6 hex = 48 bits of entropy — collision risk is astronomically low even with + thousands of switches on the same host. +- **Crash recovery**: `brctl delete` (best-effort, ignore if not found) then + `brctl create` — stale interfaces from a previous abnormal shutdown are + reclaimed automatically when the switch is re-created. + +## Controller integration + +No controller or API contract changes are required. The migration is entirely +compute-internal: + +- `node_types.BUILTIN_NODE_TYPES` already classified `ethernet_switch` as a + builtin, always-running node. +- `udp_link.create()` pushes the NIO to the switch via the standard + `POST /adapters/0/ports/{p}/nio` endpoint (same as Dynamips). +- The REST API paths, request/response schemas, and port model + (`EthernetSwitchPort`: type/vlan/ethertype) are unchanged. +- `/start`, `/stop`, `/suspend`, `/reload` return 405 (switch is always-on). + +The sole observable difference: the `console` field in the response is now +`null` (the switch has no console; `console_type="none"` makes `BaseNode` +skip TCP port reservation). The old Dynamips ethsw returned an unused TCP +port number. Both are valid under `Optional[int]`. + +## Known limitations + +### Default PVID 1 must be deleted explicitly + +A port freshly enslaved to a `vlan_filtering` bridge inherits default PVID 1 +(PVID + Egress Untagged). Access/trunk mode application must issue +`vlan_del … 1` first — `vlan_add … pvid` moves the PVID but does not remove +the old PVID's membership. This matches iproute2 semantics and is documented +in `../ubridge/doc/brctl.md#limitations`. + +### QinQ is outer-tag (S-VLAN) only + +With `setvlanproto 0x88a8` the bridge filters on the outer S-tag; the inner +C-tag passes through transparently. Selective QinQ (inner-VLAN classification +or remapping) requires `IFLA_BRIDGE_VLAN_TUNNEL_INFO` which is not implemented. +Documented in `../ubridge/doc/brctl.md#limitations`. + +### Ethertype 0x9100 / 0x9200 + +The GNS3 schema allows legacy QinQ ethertypes `0x9100` and `0x9200`, but the +Linux kernel bridge only supports `0x8100` (802.1Q) and `0x88a8` (802.1ad). +Configuring these on a qinq port produces a `NodeError` at creation/update +time. Handling policy (map to 0x88A8 + warn vs. reject with error) is +pending per design discussion. + +### No FDB read/write + +The `brctl` module exposes no `fdb_show`/`fdb_flush`. The kernel bridge +learns and ages MAC entries autonomously; uBridge has never exposed MAC-table +access and gns3-server does not consume it. Consumers that need the FDB +(e.g., a WebUI switch view) should read `/sys/class/net/
/brforward` or +`bridge fdb show dev
` directly, without uBridge involvement. + +## Troubleshooting + +### Docker iptables: FORWARD chain DROP + +Docker sets the iptables `FORWARD` chain default policy to `DROP` when the +Docker daemon starts. This blocks **all** forwarded traffic through kernel +bridges on the host, including `gns3*` bridges. + +**Symptoms**: nodes can send frames into the bridge (visible in `tcpdump -i +gns3*`) but never receive unicast replies. ARP and multicast may work +because they flood, but unicast forwarding silently fails. + +**Fix**: +```bash +sudo iptables -P FORWARD ACCEPT +``` + +### Bridge left DOWN after creation + +`brctl create` creates the bridge but leaves it administratively DOWN. +The node now sends `link set … up` after `brctl create`. If forwarding +is not working, verify: +```bash +ip -d link show gns3* | grep -E "state|vlan_filtering" +``` + +### Kernel version differences + +This implementation has been tested on Linux 7.1.2-1-default (x86_64) with +uBridge installed via `make install` (cap_net_admin,cap_net_raw=ep). The +ubridge `brctl` module has a 168-test suite covering kernel-side VLAN +behaviour on this kernel.