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/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. 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..a46366dd0 100644 --- a/gns3server/compute/builtin/nodes/ethernet_switch.py +++ b/gns3server/compute/builtin/nodes/ethernet_switch.py @@ -14,14 +14,45 @@ # 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``. +""" 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 +63,101 @@ 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}" + + 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 +165,375 @@ 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. + + 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 + # 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') + 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..16677686b 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,24 @@ 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 delete "{br}"'), + call(f'brctl create "{br}"'), + call(f'link set "{br}" up'), + call(f'brctl vlanfiltering "{br}" on'), + ]) async def test_ethernet_switch_get( self, app: FastAPI, @@ -87,7 +109,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 +119,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 +134,6 @@ class TestEthernetSwitchNodesRoutes: ) assert response.status_code == status.HTTP_201_CREATED - async def test_ethernet_switch_update( self, app: FastAPI, @@ -126,10 +142,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 +154,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 +167,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 +184,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 +208,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 +232,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 +243,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 +254,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 +265,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 +276,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 +284,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 +291,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 +360,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 +367,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 +382,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}")