diff --git a/docs/features/dynamips-kernel-datapath.md b/docs/features/dynamips-kernel-datapath.md index 474af3936..64e18ad02 100644 --- a/docs/features/dynamips-kernel-datapath.md +++ b/docs/features/dynamips-kernel-datapath.md @@ -149,7 +149,7 @@ enable_kernel_datapath = True ## Roadmap -The Ethernet switch is next (its ports are already uBridge-owned TAPs; the -kernel link should attach the peer's anchor straight into the switch's -per-node bridge), then the IOL Docker node. Cross-compute kernel links need +The Ethernet switch has landed (see `ethernet-switch-kernel-datapath.md` — +it absorbs the peer's anchor into its own kernel bridge; switch-to-switch +cascades and the IOL Docker node are next). Cross-compute kernel links need VXLAN/GENEVE encapsulation — deferred until every node type is kernelized. diff --git a/docs/features/ethernet-switch-kernel-datapath.md b/docs/features/ethernet-switch-kernel-datapath.md new file mode 100644 index 000000000..2c309707a --- /dev/null +++ b/docs/features/ethernet-switch-kernel-datapath.md @@ -0,0 +1,158 @@ + + +# Ethernet switch kernel datapath + +The builtin Ethernet switch has been a real Linux kernel bridge since its +brctl migration: one bridge per switch node, every port a persistent TAP +enslaved to it (see `ethernet-switch-ubridge-brctl-migration` design notes +in `gns3server/compute/builtin/nodes/ethernet_switch.py`). What remained on +the userspace path was the *cable*: each port's TAP doubled as a uBridge +relay endpoint, and a link to another node crossed +`peer anchor/UDP tunnel → uBridge relay ↔ port TAP → bridge` — two extra +copies of every frame. + +The kernel datapath removes the cable's userspace leg: the switch absorbs +the **peer's anchor directly into its own bridge**. The peer's veth host +end / TAP becomes the switch port; frames cross +`peer anchor → switch bridge → other switch ports` entirely in the kernel. + +``` + relay (before): peer ──UDP── ubridge[nio_udp↔nio_tap] ──tap── kernel bridge + kernel (now): peer anchor ──────────────────────────► kernel bridge + (the peer's own interface IS the switch port) +``` + +## Datapath selection + +A link with an `ethernet_switch` endpoint rides the fast path when the +**peer** can anchor (same compute; Docker always, QEMU/IOU/Dynamips per +their capabilities) — the switch side needs no anchoring capability of its +own, its bridge and brctl exist by construction. Exclusions, all staying +on the relay: + +* **switch ↔ switch**: a kernel interface can belong to exactly one + bridge, so cascading two switch bridges needs a veth pair between them — + a separate mechanism, deferred; +* anchor-less peers (VPCS, cloud, the Dynamips-hosted hub); +* cross-compute links, non-Ethernet ports: the common rules. + +## The wire format: one NIO names the other end's interface + +``` + controller ──► switch compute: nio_anchor { anchor: "gv00010203e0p0", filters, markers } + controller ──► peer compute: nio_bridge { bridge: null, filters: {}, markers } +``` + +* `nio_anchor` (new, `NIOAnchor`) is the mirror of `nio_bridge`: "enslave + *this* interface into *my own* bridge". The switch joins the anchor and + applies the port's VLAN mode to it (`brctl addif` + the access/dot1q/ + qinq composition), exactly as it programs its own port TAPs. +* `nio_bridge` with `bridge: null` on the peer end means "externally + bridged": the peer keeps everything its node type does around the anchor + (Dynamips opens the TAP in the hypervisor, IOU binds the fabric to it, + QEMU's netdev, Docker's carrier) and applies markers/impairments, but + never touches bridge membership — including on detach, where deleting a + bridge it does not own would take the switch down. + +The anchor's **name** is the shared naming contract +(`gns3server/utils/kernel_anchor.py`): `g{node_id[:8]}e{adapter}p{port}`, +the same function the four node types create their anchors with. It is a +pure function of (node type, node id, adapter, port) — valid whether or +not the peer is running, which is what makes links to stopped nodes +wireable at all. + +**Ownership.** The switch owns everything about the anchor's bridge state +(membership, VLANs). The link's *link state* — capture, markers, tc +impairments, suspend — is attached to the same interface by exactly one +end: filters and capture go to the switch (the controller assigns them +single-sided; both ends would hit one interface twice), suspend rides both +(same admin state either way), markers follow the usual capture-node +routing and work from either end. + +## Stopped peers: deferred join and the re-push + +The peer's anchor exists only while the peer runs. A link created against +a stopped peer cannot be joined immediately, so the switch **defers**: it +remembers the anchor and touches nothing (the link must not fail — this is +the normal case when a project reopens while nodes are still starting). + +The controller completes the wiring: after any node of a link reaches the +started state (`Node.start` → `UDPLink.node_started`), a switch link's NIO +is re-pushed to the switch, which joins the anchor now. The same re-join +runs when a peer restart *replaced* its interface under the same name +(QEMU recreates its TAPs at every start): the membership is checked against +the kernel (`/sys/class/net//brif/`), not assumed. + +Port setting changes (`ports_mapping` update) edit VLAN membership **in +place**: only the ports whose mode/VLAN actually changed are touched, and +each change deletes the previous mode's entries the new mode does not want +and adds the new mode's (the dot1q admits-all entry is one range operation +in both directions). A port never leaves the bridge to change its VLANs — +no traffic gap, no FDB flush, and nothing that detaches an interface this +switch does not own. + +This replaced an earlier "reset by re-enslaving" shortcut (`delif` + +`addif`, so the kernel would hand the port a fresh default membership — +used because a mode change must drop stale entries, e.g. dot1q's all-VIDs +range). On the switch's own relay TAPs it merely cost a brief traffic gap; +on **absorbed anchors** re-enslaving an interface owned by another node +proved to be where links died: live testing showed an anchor occasionally +losing its bridge membership right after a `ports_mapping` update (later +updates then failed `delif` with EINVAL), reproducibly with qemu, IOU and +Dynamips peers, never with Docker's veths. Replaying the exact command +sequence by hand against uBridge (held TAPs, no server involved) did not +reproduce it, and the in-place reconcile removes the operation entirely — +the failure mode no longer exists by construction. + +## Verified + +Unit level (all under `tests/`): the switch's kernel-port lifecycle (join +with VLAN composition, deferred join, re-join, detach with marker/tc/ +carrier teardown, capture on the anchor, batch acceptance for project +open); the in-place VLAN reconcile (every mode transition, untouched ports +emit nothing, relay TAPs and absorbed anchors alike); the shared naming +contract; the mixin's externally-bridged branch (no bridge membership work +in attach/remove, the bridge sweep never deletes a bridge the node does not +own); the controller's eligibility table, wire format (anchor name, +single-sided filters, endpoint-order alignment), `kernel_datapath` +reporting and the node-start re-push. + +Live end-to-end on a real server (`tests/e2e/test_ethernet_switch_kernel_datapath.py` +plus scripted reproduction rounds over the REST API): + +* two qemu peers (the previously 2-out-of-2 failing scenario): deferred + links → start → both anchors joined; four `ports_mapping` cycles + (10/20 ↔ 10/10) with both anchors in the bridge throughout, no failed + update, kernel VLAN state correct at every step; +* a mixed switch (2 × IOU + 2 × Dynamips on one bridge, four kernel + ports): four anchors stable across updates that changed all ports and + across updates that changed only two; +* Docker peers with real ICMP: isolating VLANs cuts the ping with **both + anchors still enslaved** (the filter does the cutting, not a broken + link); restoring the VLAN restores the ping; +* VPCS peers (the relay path, same switch-side mechanics as cloud): the + relay TAPs likewise stay enslaved across VLAN changes, with a real ping + proving isolation and recovery. + +The host-side checks throughout are the kernel's own view: bridge +membership under `/sys/class/net//brif/`, the `master` link +attribute, `bridge vlan show`, and live pings where the peer allows it. + +## Configuration + +Same switch as every node type: + +```ini +[Server] +enable_kernel_datapath = True +``` + +## Roadmap + +Switch-to-switch links (a veth pair joining the two kernel bridges, with +each side's port mode applied at its own end) and the IOL Docker node +(`bridge add_nio_tap`); cross-compute kernel links need VXLAN/GENEVE +encapsulation, deferred until every node type is kernelized. diff --git a/docs/features/iou-kernel-datapath.md b/docs/features/iou-kernel-datapath.md index 4b9444433..17be06e3c 100644 --- a/docs/features/iou-kernel-datapath.md +++ b/docs/features/iou-kernel-datapath.md @@ -170,9 +170,10 @@ enable_kernel_datapath = True ## Roadmap Dynamips has landed (see `dynamips-kernel-datapath.md` — the hypervisor -opens uBridge-created TAPs, the same shape QEMU uses). Next: the Ethernet -switch (its ports are already uBridge-owned TAPs; the kernel link should -attach the peer's anchor straight into the switch's per-node bridge), then -the IOL Docker node (same `bridge add_nio_tap` shape as this one). -Cross-compute kernel links need VXLAN/GENEVE encapsulation — deferred -until every node type is kernelized. +opens uBridge-created TAPs, the same shape QEMU uses), and so has the +Ethernet switch (see `ethernet-switch-kernel-datapath.md` — it absorbs the +peer's anchor into its own kernel bridge). What remains: switch-to-switch +cascades (a veth pair between the two kernel bridges) and the IOL Docker +node (same `bridge add_nio_tap` shape as this one). Cross-compute kernel +links need VXLAN/GENEVE encapsulation — deferred until every node type is +kernelized. diff --git a/gns3server/api/routes/compute/ethernet_switch_nodes.py b/gns3server/api/routes/compute/ethernet_switch_nodes.py index e6e13e94d..47b8b8604 100644 --- a/gns3server/api/routes/compute/ethernet_switch_nodes.py +++ b/gns3server/api/routes/compute/ethernet_switch_nodes.py @@ -23,7 +23,7 @@ through uBridge's ``brctl`` module (see """ import os -from typing import Any +from typing import Any, Union from uuid import UUID from fastapi import APIRouter, Body, Depends, HTTPException, Path, status @@ -103,16 +103,20 @@ async def update_ethernet_switch( Update an Ethernet switch. """ - data = jsonable_encoder(node_data, exclude_unset=True) - if "name" in data and node.name != data["name"]: - node.name = data["name"] - if "usage" in data: - node.usage = data["usage"] - if "ports_mapping" in data: - node.ports_mapping = data["ports_mapping"] - await node.update_port_settings() - if "console_type" in data: - node.console_type = data["console_type"] + node_data = jsonable_encoder(node_data, exclude_unset=True) + if "name" in node_data and node.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: + # capture the mapping before the setter replaces it: the VLAN + # reconcile diffs old against new to touch only what changed, in + # place (the setter itself keeps the port-count guard) + previous_mapping = [dict(port) for port in node.ports_mapping] + node.ports_mapping = node_data["ports_mapping"] + await node.update_port_settings(previous_mapping) + if "console_type" in node_data: + node.console_type = node_data["console_type"] node.updated() return node.asdict() @@ -174,15 +178,20 @@ def reload_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None: @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, - response_model=schemas.UDPNIO, + response_model=Union[schemas.UDPNIO, schemas.AnchorNIO], ) async def create_ethernet_switch_nio( *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, - nio_data: schemas.UDPNIO, + nio_data: Union[schemas.UDPNIO, schemas.AnchorNIO], node: EthernetSwitch = Depends(dep_node), -) -> schemas.UDPNIO: +) -> Union[schemas.UDPNIO, schemas.AnchorNIO]: + """ + Add a NIO (Network Input/Output) to the node: a UDP NIO wires the relay + datapath, an anchor NIO absorbs the peer's kernel anchor into this + switch's kernel bridge. The adapter number on the switch is always 0. + """ nio = Builtin.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True)) await node.add_nio(nio, port_number) @@ -192,19 +201,21 @@ async def create_ethernet_switch_nio( @router.put( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, - response_model=schemas.UDPNIO, + response_model=Union[schemas.UDPNIO, schemas.AnchorNIO], ) async def update_ethernet_switch_nio( *, adapter_number: int = Path(..., ge=0, le=0), port_number: int, - nio_data: schemas.UDPNIO, + nio_data: Union[schemas.UDPNIO, schemas.AnchorNIO], node: EthernetSwitch = Depends(dep_node), -) -> schemas.UDPNIO: +) -> Union[schemas.UDPNIO, schemas.AnchorNIO]: """ Update a NIO (Network Input/Output) on the node: re-apply the packet - filters and traffic-insight markers carried by the NIO onto the port's - uBridge relay. The adapter number on the switch is always 0. + filters, traffic-insight markers and suspend state carried by the NIO — + on the port's uBridge relay for a relay port, on the absorbed anchor + (tc + kernel markers + carrier) for a kernel port. The adapter number + on the switch is always 0. """ nio = node.get_nio(port_number) @@ -212,6 +223,10 @@ async def update_ethernet_switch_nio( if nio_data.filters: nio.filters = nio_data.filters nio.markers = nio_data.markers or {} + # Suspend is what the compute turns into an admin-down anchor on a + # kernel link (native carrier), so it must reach the NIO like it does + # on the other node routes. + nio.suspend = getattr(nio_data, "suspend", None) or False await node.update_nio(port_number, nio) return nio.asdict() diff --git a/gns3server/compute/base_manager.py b/gns3server/compute/base_manager.py index d63817152..35c8be784 100644 --- a/gns3server/compute/base_manager.py +++ b/gns3server/compute/base_manager.py @@ -41,6 +41,7 @@ from ..utils import force_unix_path from ..utils.images import default_images_directory, images_directories, list_images, md5sum, remove_checksum from .base_node import BaseNode from .error import ImageMissingError, NodeError +from .nios.nio_anchor import NIOAnchor from .nios.nio_bridge import NIOBridge from .nios.nio_ethernet import NIOEthernet from .nios.nio_tap import NIOTAP @@ -380,6 +381,14 @@ class BaseManager: # uBridge's AF_PACKET marker module (marker add_kernel). nio.markers = nio_settings.get("markers", {}) nio.suspend = nio_settings.get("suspend", False) + elif nio_settings["type"] == "nio_anchor": + # The node-owned-bridge mirror of nio_bridge: the Ethernet + # switch joins the named foreign anchor to its own kernel + # bridge (AnchorNIO/NIOAnchor). + nio = NIOAnchor(nio_settings["anchor"]) + nio.filters = nio_settings.get("filters", {}) + nio.markers = nio_settings.get("markers", {}) + nio.suspend = nio_settings.get("suspend", False) elif nio_settings["type"] == "nio_tap": tap_device = nio_settings["tap_device"] # if not is_interface_up(tap_device): diff --git a/gns3server/compute/builtin/nodes/ethernet_switch.py b/gns3server/compute/builtin/nodes/ethernet_switch.py index 929325976..63f26153f 100644 --- a/gns3server/compute/builtin/nodes/ethernet_switch.py +++ b/gns3server/compute/builtin/nodes/ethernet_switch.py @@ -37,12 +37,16 @@ be the switch). ESW ``access``/``dot1q``/``qinq`` port modes are composed from the ``brctl`` VLAN primitives here -- see ``_apply_port_vlan``. """ +import contextlib import logging +import os from gns3server.compute.ubridge.ubridge_error import UbridgeError from ...base_node import BaseNode from ...error import NodeError +from ...kernel_datapath import KernelDatapathMixin +from ...nios.nio_anchor import NIOAnchor from ...nios.nio_udp import NIOUDP log = logging.getLogger(__name__) @@ -55,7 +59,7 @@ _SUPPORTED_VLAN_ETHERTYPE = {"0x8100", "0x88a8"} _QINQ_ETHERTYPE = "0x88a8" -class EthernetSwitch(BaseNode): +class EthernetSwitch(KernelDatapathMixin, BaseNode): """ Ethernet switch. @@ -75,9 +79,11 @@ class EthernetSwitch(BaseNode): self._nios = {} self._tap_by_port = {} # port_number -> kernel TAP enslaved to the bridge + self._kernel_ports = {} # port_number -> foreign anchor absorbed into 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 + self._ubridge_tc_caps = None # probed once per uBridge process (mixin) # 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. @@ -195,11 +201,16 @@ class EthernetSwitch(BaseNode): if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running(): await self._stop_ubridge() await self._start_ubridge(self._ubridge_require_privileged_access) + # A fresh uBridge process: the tc capabilities must be probed again. + self._ubridge_tc_caps = None 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) + if isinstance(self._nios[port_number], NIOAnchor): + await self._add_kernel_port(self._nios[port_number], port_number) + else: + await self._add_ubridge_connection(self._nios[port_number], port_number) except (UbridgeError, NodeError) as e: self._started = False raise e @@ -275,11 +286,24 @@ class EthernetSwitch(BaseNode): 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: + # Project close runs every node's close concurrently: a peer's + # anchor may still be enslaved at this instant (its node's close + # is in flight), which would make the bridge delete fail EBUSY + # and leak an empty bridge. Detach every member we know first — + # a peer anchor that is already gone answers ENOENT (suppressed; + # the kernel removed it with the interface), and our own TAPs go + # with the relay teardown — so the delete always sees an empty + # bridge. The foreign anchors themselves are not ours to delete. + for anchor in self._kernel_ports.values(): + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{anchor}"') + for tap in self._tap_by_port.values(): + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"') try: - # Deleting the bridge releases its enslaved TAPs; uBridge destroys + # Deleting the bridge releases any enslaved TAPs; uBridge destroys # them when it stops below. await self._ubridge_send(f'brctl delete "{self._bridge_name}"') except UbridgeError as e: @@ -287,6 +311,8 @@ class EthernetSwitch(BaseNode): self._bridge_created = False self._bridge_proto_set = False self._bridge_name = None + self._tap_by_port.clear() + self._kernel_ports.clear() self._started = False await self._stop_ubridge() @@ -301,19 +327,27 @@ class EthernetSwitch(BaseNode): """ Adds a NIO as a new port on this switch. + Two kinds are accepted: a UDP NIO (relay datapath — the per-port + uBridge relay ends in a bridge TAP) and the kernel datapath's + NIOAnchor (the peer's anchor is absorbed straight into this switch's + kernel bridge). + :param nio: NIO instance to add :param port_number: port to allocate for the NIO """ 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") + if not isinstance(nio, (NIOUDP, NIOAnchor)): + raise NodeError("Ethernet switch ports only support UDP NIOs and anchor NIOs") log.debug(f'Ethernet switch "{self._name}" [{self._id}]: NIO {nio} bound to port {port_number}') try: await self.start() - await self._add_ubridge_connection(nio, port_number) + if isinstance(nio, NIOAnchor): + await self._add_kernel_port(nio, port_number) + else: + await self._add_ubridge_connection(nio, port_number) self._nios[port_number] = nio except (NodeError, UbridgeError) as e: log.error(f'Cannot add NIO on Ethernet switch "{self._name}": {e}') @@ -351,6 +385,95 @@ class EthernetSwitch(BaseNode): await self._ubridge_send(f"bridge start {ubridge_bridge}") self._tap_by_port[port_number] = tap + # ------------------------------------------------------------------ # + # kernel datapath: absorb the peer's anchor (fast path) + # ------------------------------------------------------------------ # + + def _kernel_host_ifc(self, adapter_number, port_number=0): + """ + The foreign anchor absorbed on a port, or None for a relay port. The + switch has no adapters, so *adapter_number* is always 0. + """ + + return self._kernel_ports.get(port_number) + + def _kernel_anchors(self): + """ + Every absorbed anchor — the interfaces this switch bridges and on + which kernel markers may attach (the mixin's marker polymorphism). + """ + + return set(self._kernel_ports.values()) + + def _kernel_error(self, message): + return NodeError(message) + + async def _add_kernel_port(self, nio, port_number): + """ + Absorbs the peer's anchor for one port: joins the anchor straight to + this switch's kernel bridge and applies the port's VLAN mode — no + relay, no per-port TAP, the peer anchor *is* the port. + + The anchor only exists on the host while the peer is running (its + compute creates it at node start), so a link created against a + stopped peer defers the join: the port remembers the anchor and + ``update_nio`` completes the wiring when the controller re-pushes + the NIO after the peer starts (UdpLink.node_started). A peer + restart can also replace the interface under the same name, so the + membership is re-checked rather than assumed. + + The link state the anchored end carries (capture, markers, tc + impairments) is applied by the shared mixin flow: NIOAnchor reports + no per-link bridge, so only the link state is applied here, while + the bridge membership above is this switch's own. + """ + + 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}'") + + anchor = nio.anchor + # Register before _kernel_attach: the marker reconcile recognises + # kernel anchors through _kernel_anchors(). + self._kernel_ports[port_number] = anchor + if not os.path.exists(f"/sys/class/net/{anchor}"): + log.info( + 'Ethernet switch "{name}" [{id}]: anchor {anchor} for port {port} does not exist yet ' + "(peer not started); join deferred".format( + name=self._name, id=self._id, anchor=anchor, port=port_number + ) + ) + return + try: + await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{anchor}"') + await self._apply_port_vlan(port_settings, anchor) + await self._kernel_attach(anchor, nio) + await self._set_adapter_carrier(0, not nio.suspend, port_number) + except (NodeError, UbridgeError): + self._kernel_ports.pop(port_number, None) + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{anchor}"') + raise + + async def _remove_kernel_port(self, port_number): + """ + Releases an absorbed anchor: detaches the link state (markers, + impairments, carrier) and takes the anchor out of the switch bridge. + The anchor itself belongs to the peer and survives. + """ + + anchor = self._kernel_ports.pop(port_number, None) + if anchor is None: + return + await self._remove_kernel_markers(anchor) + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'tc reset "{anchor}"') + if self._bridge_created: + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{anchor}"') + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'link set "{anchor}" down') + async def _delete_ubridge_connection(self, port_number): """ Tears down one port's wiring: release the TAP from the bridge and delete @@ -388,7 +511,10 @@ class EthernetSwitch(BaseNode): log.debug(f'Ethernet switch "{self._name}" [{self._id}]: NIO {nio} removed from 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) + if isinstance(nio, NIOAnchor): + await self._remove_kernel_port(port_number) + else: + await self._delete_ubridge_connection(port_number) return nio def get_nio(self, port_number): @@ -405,32 +531,85 @@ class EthernetSwitch(BaseNode): async def update_nio(self, port_number, nio): """ - Re-applies uBridge filters/markers for a port (called when a link is updated). + Re-applies filters/markers (and carrier) for a port when a link is + updated: on the per-port uBridge relay for a relay port, on the + absorbed anchor (tc + kernel markers) for a kernel port. """ + if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running()): + return + if port_number in self._kernel_ports: + anchor = self._kernel_ports[port_number] + if not os.path.exists(f"/sys/class/net/{self._bridge_name}/brif/{anchor}"): + # Not joined (a deferred link whose peer has just started, or + # a peer restart that replaced its anchor under the same + # name): complete the whole wiring now. + await self._add_kernel_port(nio, port_number) + return + # Kernel port: reconcile on the anchor — markers, tc impairments + # and the carrier the suspend flag drives. No re-joining, no + # VLAN re-programming (the port mode did not change). + await self._kernel_update(anchor, nio) + await self._set_adapter_carrier(0, not nio.suspend, port_number) + return 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) + 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): + async def _reconfigure_port_vlan(self, iface, old_settings, new_settings): """ - 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. + Moves one port's VLAN membership from its previous mode to the new one + **in place**: the port never leaves the bridge, so there is no traffic + gap, no FDB flush, and nothing that detaches an interface this switch + does not own (an absorbed kernel anchor belongs to the peer's node — + re-enslaving it from here is not this switch's business). + + Each mode's membership, as installed on a clean port by + ``_apply_port_vlan``: access(N) = {N, PVID untagged}; dot1q(V) = + {1..4094, tagged} plus V as PVID untagged (the all-VIDs entry is a + single range entry); qinq(O) = {O, PVID untagged}. The transition + deletes exactly the previous mode's entries the new mode does not + want, then adds the new mode's entries — the dot1q range is added and + deleted as one range operation. """ - await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"') - await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{tap}"') + old_type, old_vlan = old_settings["type"], int(old_settings["vlan"]) + new_type, new_vlan = new_settings["type"], int(new_settings["vlan"]) + if old_type == new_type and old_vlan == new_vlan: + return + + br = self._bridge_name + commands = [] + + if old_type == "dot1q" and new_type != "dot1q": + # the admits-all-VIDs range, the old native VLAN included in it + commands.append(f'brctl vlan_del "{br}" "{iface}" 1 vid 4094') + elif old_type == "dot1q": + if old_vlan != new_vlan: + commands.append(f'brctl vlan_del "{br}" "{iface}" {old_vlan}') + elif new_type == "dot1q" or old_vlan != new_vlan: + # access/qinq single-VID entry, replaced by the new mode's + commands.append(f'brctl vlan_del "{br}" "{iface}" {old_vlan}') + + if new_type == "dot1q": + if old_type != "dot1q": + commands.append(f'brctl vlan_add "{br}" "{iface}" 1 vid 4094') + commands.append(f'brctl vlan_add "{br}" "{iface}" {new_vlan} pvid untagged') + else: + commands.append(f'brctl vlan_add "{br}" "{iface}" {new_vlan} pvid untagged') + + for command in commands: + await self._ubridge_send(command) 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. + Translates an ESW port mode into ``brctl`` VLAN primitives, for a + **newly wired** port. 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 @@ -438,6 +617,9 @@ class EthernetSwitch(BaseNode): 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. + + Mode *changes* on an already-wired port go through + ``_reconfigure_port_vlan`` (in place, no re-enslaving) instead. """ br = self._bridge_name @@ -458,23 +640,41 @@ class EthernetSwitch(BaseNode): else: raise NodeError(f"Unknown port type '{port_type}' on Ethernet switch '{self.name}'") - async def update_port_settings(self): + async def update_port_settings(self, previous_mapping=None): """ - 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. + Reconciles port settings after a ``ports_mapping`` update. Only the + wired ports whose mode/VLAN actually changed are touched, and each + change is applied in place (``_reconfigure_port_vlan``) — no port is + ever detached from the bridge just to change its VLANs, on the + switch's own relay TAPs and on absorbed kernel anchors alike. + + :param previous_mapping: the ports_mapping as it was before this + update (the caller's duty — the setter has already stored the new + one); without it nothing can be diffed and no port is touched. """ 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: + + previous = {port["port_number"]: port for port in (previous_mapping or [])} + wired = dict(self._tap_by_port) + for port_number, anchor in self._kernel_ports.items(): + wired[port_number] = anchor + for port_number, iface in wired.items(): + new_settings = self._port_settings(port_number) + if new_settings is None: continue - await self._reset_port_vlan(tap) - await self._apply_port_vlan(port_settings, tap) + old_settings = previous.get(port_number) + if old_settings is None: + # a wired port always has previous settings; without them a + # diff is impossible and re-enslaving would be guessing + log.warning( + 'Ethernet switch "{name}" [{id}]: no previous settings for wired port {port}; ' + "VLAN reconfiguration skipped".format(name=self._name, id=self._id, port=port_number) + ) + continue + await self._reconfigure_port_vlan(iface, old_settings, new_settings) # ------------------------------------------------------------------ # # capture @@ -482,7 +682,9 @@ class EthernetSwitch(BaseNode): async def start_capture(self, port_number, output_file, data_link_type="DLT_EN10MB"): """ - Starts a packet capture on a port (uBridge captures on the per-port relay). + Starts a packet capture on a port — on the per-port relay bridge for + a relay port, on the absorbed anchor (AF_PACKET, capture start_kernel) + for a kernel port. :param port_number: allocated port number :param output_file: PCAP destination file for the capture @@ -494,9 +696,17 @@ class EthernetSwitch(BaseNode): 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.debug(f'Ethernet switch "{self.name}" [{self.id}]: starting packet capture on port {port_number}') + anchor = self._kernel_ports.get(port_number) + if anchor is not None: + await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"') + else: + ubridge_bridge = self._ubridge_bridge_name(port_number) + await self._ubridge_send(f'bridge start_capture {ubridge_bridge} "{output_file}"') + log.debug( + '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): """ @@ -510,6 +720,13 @@ class EthernetSwitch(BaseNode): 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.debug(f'Ethernet switch "{self.name}" [{self.id}]: stopping packet capture on port {port_number}') + if port_number in self._kernel_ports: + await self._ubridge_send("capture stop_kernel") + else: + ubridge_bridge = self._ubridge_bridge_name(port_number) + await self._ubridge_send(f"bridge stop_capture {ubridge_bridge}") + log.debug( + 'Ethernet switch "{name}" [{id}]: stopping packet capture on port {port}'.format( + name=self.name, id=self.id, port=port_number + ) + ) diff --git a/gns3server/compute/docker/docker_kernel_datapath.py b/gns3server/compute/docker/docker_kernel_datapath.py index 4138bbce2..f0b215035 100644 --- a/gns3server/compute/docker/docker_kernel_datapath.py +++ b/gns3server/compute/docker/docker_kernel_datapath.py @@ -29,6 +29,7 @@ from gns3server.compute.kernel_datapath import KernelDatapathMixin from gns3server.compute.ubridge.ubridge_error import UbridgeError, UbridgeNamespaceError from .docker_error import DockerError +from gns3server.utils.kernel_anchor import anchor_suffix, kernel_anchor_name import logging @@ -48,12 +49,15 @@ class DockerKernelDatapathMixin(KernelDatapathMixin): kernel bridge); the guest end is moved into the container namespace and renamed. The ``gv``/``gc`` prefixes keep these out of the ``gns3`` bridge/TAP name space and serve as search keys for stale-interface - cleanup. 8 hex chars of the node id + adapter/port keep the names - unique and within IFNAMSIZ (15). + cleanup; the host end's name is the shared anchor naming contract + (utils.kernel_anchor), which the controller also uses to name a + peer's anchor for an Ethernet-switch link. """ - suffix = f"{self._id.replace('-', '')[:8]}e{adapter_number}p{port_number}" - return f"gv{suffix}", f"gc{suffix}" + return ( + kernel_anchor_name("docker", self._id, adapter_number, port_number), + f"gc{anchor_suffix(self._id, adapter_number, port_number)}", + ) # ------------------------------------------------------------------ # Anchor hooks (see KernelDatapathMixin) diff --git a/gns3server/compute/dynamips/nodes/router.py b/gns3server/compute/dynamips/nodes/router.py index 6c8afaf5f..4cb6cf964 100644 --- a/gns3server/compute/dynamips/nodes/router.py +++ b/gns3server/compute/dynamips/nodes/router.py @@ -36,6 +36,7 @@ from gns3server.utils.asyncio import monitor_process, wait_run_in_executor from gns3server.utils.file_watcher import FileWatcher from gns3server.utils.hostname import is_ios_hostname_valid from gns3server.utils.images import md5sum +from gns3server.utils.kernel_anchor import kernel_anchor_name from ...base_node import BaseNode from ...error import NodeError @@ -1547,14 +1548,13 @@ class Router(KernelDatapathMixin, BaseNode): def _tap_name(self, slot_number, port_number): """ - Deterministic anchor TAP name for a slot/port. The ``gd`` prefix - keeps it out of the ``gns3`` bridge/TAP name space (and apart from - Docker's ``gv``/``gc``, QEMU's ``gq`` and IOU's ``gi``); 8 hex chars - of the node id plus slot/port keep it unique and within IFNAMSIZ - (15), even for WIC port numbers (16, 32, 48). + Deterministic anchor TAP name for a slot/port (the shared + utils.kernel_anchor naming contract — the controller names a peer's + anchor with the same function when an Ethernet switch absorbs it; + fits IFNAMSIZ even for WIC port numbers 16/32/48). """ - return f"gd{self._id.replace('-', '')[:8]}e{slot_number}p{port_number}" + return kernel_anchor_name("dynamips", self._id, slot_number, port_number) def _kernel_host_ifc(self, slot_number, port_number=0): """ diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index 7c61c7d18..676d46e3a 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -43,6 +43,7 @@ from gns3server.utils.asyncio.ssh_server import AsyncioSSHServer from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer from gns3server.utils.file_watcher import FileWatcher from gns3server.utils.hostname import is_ios_hostname_valid +from gns3server.utils.kernel_anchor import kernel_anchor_name from ..adapters.ethernet_adapter import EthernetAdapter from ..adapters.serial_adapter import SerialAdapter @@ -862,13 +863,12 @@ class IOUVM(KernelDatapathMixin, BaseNode): def _tap_name(self, adapter_number, port_number): """ - Deterministic anchor TAP name for an Ethernet bay/unit. The ``gi`` - prefix keeps it out of the ``gns3`` bridge/TAP name space (and apart - from Docker's ``gv``/``gc`` and QEMU's ``gq``); 8 hex chars of the - node id plus bay/unit keep it unique and within IFNAMSIZ (15). + Deterministic anchor TAP name for an Ethernet bay/unit (the shared + utils.kernel_anchor naming contract — the controller names a peer's + anchor with the same function when an Ethernet switch absorbs it). """ - return f"gi{self._id.replace('-', '')[:8]}e{adapter_number}p{port_number}" + return kernel_anchor_name("iou", self._id, adapter_number, port_number) def _kernel_host_ifc(self, adapter_number, port_number=0): """ diff --git a/gns3server/compute/kernel_datapath.py b/gns3server/compute/kernel_datapath.py index a993ac1fa..3e8665af2 100644 --- a/gns3server/compute/kernel_datapath.py +++ b/gns3server/compute/kernel_datapath.py @@ -146,15 +146,23 @@ class KernelDatapathMixin: Both link endpoints run this concurrently and either may create the bridge first: an existing bridge is success instead of a create race failure. + + A NIO whose ``bridge`` is None means the anchor is bridged + elsewhere — an Ethernet switch absorbed it into the switch's own + bridge — so the membership work is skipped here and only the link + state the anchor carries (capture, markers, impairment filters) is + applied. Exactly one end of such a link owns the anchor's bridge + state; the other end is a passive carrier. """ - try: - await self._ubridge_send(f'brctl create "{nio.bridge}"') - except UbridgeError: - # Raises again if the bridge genuinely does not exist. - await self._ubridge_send(f'brctl show "{nio.bridge}"') - await self._ubridge_send(f'link set "{nio.bridge}" up') - await self._ubridge_send(f'brctl addif "{nio.bridge}" "{anchor}"') + if nio.bridge is not None: + try: + await self._ubridge_send(f'brctl create "{nio.bridge}"') + except UbridgeError: + # Raises again if the bridge genuinely does not exist. + await self._ubridge_send(f'brctl show "{nio.bridge}"') + await self._ubridge_send(f'link set "{nio.bridge}" up') + await self._ubridge_send(f'brctl addif "{nio.bridge}" "{anchor}"') if nio.capturing: # Restore a capture that was active before a node restart # (mirrors the relay path's start_capture). @@ -235,6 +243,11 @@ class KernelDatapathMixin: this concurrently: deleting a bridge that still has the peer's port enslaved fails with EBUSY, so the last endpoint to remove its port wins the deletion and the loser's failure is expected and suppressed. + + A NIO whose ``bridge`` is None owns no bridge here: the anchor is + bridged by an Ethernet switch, which tears its own membership down + on its side; this end only releases the link state it applied + (markers, impairments, carrier). """ host_ifc = self._kernel_host_ifc(adapter_number, port_number) @@ -248,10 +261,12 @@ class KernelDatapathMixin: await self._ubridge_send(f'tc reset "{host_ifc}"') if self.status == "started": await self._set_adapter_carrier(adapter_number, False, port_number) + if nio.bridge is not None: + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'brctl delif "{nio.bridge}" "{host_ifc}"') + if nio.bridge is not None: with contextlib.suppress(UbridgeError): - await self._ubridge_send(f'brctl delif "{nio.bridge}" "{host_ifc}"') - with contextlib.suppress(UbridgeError): - await self._ubridge_send(f'brctl delete "{nio.bridge}"') + await self._ubridge_send(f'brctl delete "{nio.bridge}"') async def _remove_kernel_bridges(self): """ @@ -269,7 +284,9 @@ class KernelDatapathMixin: return for adapter in self._ethernet_adapters: for nio in adapter.ports.values(): - if isinstance(nio, NIOBridge): + # bridge None = externally bridged (an Ethernet switch owns + # the bridge); never delete a bridge this node does not own. + if isinstance(nio, NIOBridge) and nio.bridge is not None: with contextlib.suppress(UbridgeError): await self._ubridge_send(f'brctl delete "{nio.bridge}"') diff --git a/gns3server/compute/nios/nio_anchor.py b/gns3server/compute/nios/nio_anchor.py new file mode 100644 index 000000000..cecfcc6f8 --- /dev/null +++ b/gns3server/compute/nios/nio_anchor.py @@ -0,0 +1,84 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +""" +Interface for anchor-absorbing NIOs (the Ethernet switch's kernel datapath). + +A NIO of this type instructs the node to join a *foreign* host interface — +another node's kernel-datapath anchor — into a bridge the node itself owns, +with the node's port settings applied to it. The Ethernet switch is the +consumer: its per-node kernel bridge absorbs the peer's anchor directly, so +frames cross peer anchor -> switch bridge -> switch ports entirely in the +kernel instead of riding the switch's per-port uBridge relay. + +This is the mirror of NIOBridge: NIOBridge says "enslave *my* anchor into +the bridge named here", NIOAnchor says "enslave *this* interface into my +own bridge". Impairment filters, markers and capture ride the NIO and +attach to the named anchor (tc on it, marker add_kernel / capture +start_kernel on it) — exactly one link end owns them. +""" + +from .nio import NIO + + +class NIOAnchor(NIO): + """ + Anchor-absorbing NIO. + + :param anchor: host interface to join to the node's own bridge + """ + + def __init__(self, anchor): + + super().__init__() + self._anchor = anchor + + @property + def anchor(self): + """ + Returns the interface name to absorb. + + :returns: interface name + """ + + return self._anchor + + @property + def bridge(self): + """ + Always None: this NIO owns no per-link bridge — the absorbed anchor + joins the node's *own* bridge. The None makes the shared + KernelDatapathMixin attach/remove flows skip the per-link bridge + membership work (their external-bridge case), which is exactly what + the switch needs on top of its own addif. + """ + + return None + + def __str__(self): + + return "NIO anchor" + + def asdict(self): + + return { + "type": "nio_anchor", + "anchor": self._anchor, + "suspend": self._suspended, + "filters": self._filters, + "markers": self._markers, + } diff --git a/gns3server/compute/nios/nio_bridge.py b/gns3server/compute/nios/nio_bridge.py index d65e38471..bdb00c9e5 100644 --- a/gns3server/compute/nios/nio_bridge.py +++ b/gns3server/compute/nios/nio_bridge.py @@ -25,17 +25,22 @@ filters ride the NIO and run as one tc netem qdisc per veth host end (the types without a netem equivalent — frequency_drop, bpf — stay relay-only); markers and packet capture are served by uBridge's AF_PACKET modules on the veth host end (marker add_kernel / capture start_kernel). + +``bridge`` may be ``None``: the anchor is then bridged elsewhere — an +Ethernet switch absorbed it into the switch's own kernel bridge — and this +node applies only the link state the anchor carries (carrier, markers, +impairments), never bridge membership. """ from .nio import NIO class NIOBridge(NIO): - """ Kernel-bridge NIO. - :param bridge: name of the per-link Linux kernel bridge + :param bridge: name of the per-link Linux kernel bridge, or None when + the anchor is bridged by an Ethernet switch (see module docstring) """ def __init__(self, bridge): @@ -46,7 +51,8 @@ class NIOBridge(NIO): @property def bridge(self): """ - Returns the kernel bridge name. + Returns the kernel bridge name, or None for an externally bridged + anchor. :returns: bridge name """ diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index b531d91ec..bcca97f89 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -202,13 +202,16 @@ class Link: @property def kernel_datapath(self): """ - Whether this link is wired on the kernel datapath (veth pairs - enslaved into a per-link Linux bridge — no uBridge relay in the - forwarding path). Impairment filters run as tc netem on the veth - host end; markers and capture are served by uBridge's AF_PACKET + Whether this link is wired on the kernel datapath (no uBridge relay + in the forwarding path): either both endpoints enslave their + anchors into a per-link Linux bridge (``nio_bridge``), or an + Ethernet switch absorbs the peer's anchor into its own kernel + bridge (``nio_anchor`` on the switch end, ``nio_bridge`` with no + bridge on the peer end). Impairment filters run as tc netem on the + anchors; markers and capture are served by uBridge's AF_PACKET module. """ - return any(d.get("type") == "nio_bridge" for d in (getattr(self, "_link_data", None) or [])) + return any(d.get("type") in ("nio_bridge", "nio_anchor") for d in (getattr(self, "_link_data", None) or [])) @property def markers(self): @@ -335,9 +338,7 @@ class Link: raise ControllerError( "Packet filter(s) {} only run on a kernel-datapath link (tc netem on the " "veth host end); this link is wired on the uBridge relay — delete and " - "recreate the link to switch it to the kernel datapath".format( - ", ".join(sorted(conflicts)) - ) + "recreate the link to switch it to the kernel datapath".format(", ".join(sorted(conflicts))) ) # An unchanged filters dict is normally a no-op skip, but a PUT that @@ -685,6 +686,14 @@ class Link: """ raise NotImplementedError + async def node_started(self, node): + """ + Called after a node member of the link reached the started state. + The base link has nothing to re-synchronise; UDPLink uses it for + the Ethernet-switch fast path (the switch must re-join a peer anchor + that only comes into existence when the peer starts). + """ + def default_capture_file_name(self): """ :returns: File name for a capture on this link diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index e96d4b704..3500cb2db 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -936,6 +936,11 @@ class Node: await self.post("/start", data=data, timeout=240) except asyncio.TimeoutError: raise ControllerTimeoutError(f"Timeout when starting {self._name}") + # A started node may have brought kernel anchors into existence that + # a link needs to finish wiring (the Ethernet-switch fast path joins + # the peer's anchor on the switch side — see UDPLink.node_started). + for link in self._links: + await link.node_started(self) async def stop(self): """ diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index abe1a7f78..5b37513d3 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -20,12 +20,14 @@ import asyncio import logging from gns3server.config import Config +from gns3server.utils.kernel_anchor import kernel_anchor_name from gns3server.utils.packet_filter_validation import ( KERNEL_UNSUPPORTED_FILTERS, split_kernel_only_features, validate_bpf_syntax, ) +from .compute import ComputeError from .controller_error import ControllerError, ControllerNotFoundError from .link import _UNSET, Link from .node_types import BUILTIN_NODE_TYPES @@ -111,6 +113,13 @@ class UDPLink(Link): if kernel is None: kernel = self.kernel_datapath if kernel: + # Ethernet switch fast path: the link's tc impairments apply on + # the absorbed anchor, which is a single interface — exactly one + # end may own them, and that end is the switch (it owns the + # anchor's bridge membership and port VLANs). + switch = self._switch_endpoint() + if switch is not None: + return (self._filters, {}) if switch[0] == 0 else ({}, self._filters) return self._filters, self._filters # Relay datapath: the netem-extension filters (rate, reorder, gemodel…) # have no relay equivalent. update_filters rejects them on created @@ -200,8 +209,32 @@ class UDPLink(Link): port = side.get("port") if port is not None and port.link_type != "ethernet": return False + # Ethernet switch fast path: the switch absorbs the peer's anchor + # into its own kernel bridge, so the switch side needs no anchoring + # capability of its own (its bridge and brctl already exist) — only + # the peer must be able to anchor. Two switches never qualify: a + # kernel interface belongs to exactly one bridge, so a cascaded + # link would need a veth pair between the two bridges and stays on + # the relay for now. + switch = self._switch_endpoint() + if switch is not None: + if node1.node_type == "ethernet_switch" and node2.node_type == "ethernet_switch": + return False + peer = node2 if switch[0] == 0 else node1 + return self._kernel_endpoint_ready(peer) return self._kernel_endpoint_ready(node1) and self._kernel_endpoint_ready(node2) + def _switch_endpoint(self): + """ + The Ethernet switch endpoint of this link as ``(index, node)``, or + None — the fast path where the switch owns the kernel bridge. + """ + + for index, side in enumerate(self._nodes): + if side["node"].node_type == "ethernet_switch": + return index, side["node"] + return None + @staticmethod def _kernel_endpoint_ready(node): """ @@ -250,6 +283,49 @@ class UDPLink(Link): port_number2 = self._nodes[1]["port_number"] if self._kernel_datapath_eligible(node1, node2): + node1_filters, node2_filters = self._get_node_filters(node1, node2, kernel=True) + node1_markers, node2_markers = self._get_node_markers(node1, node2) + switch = self._switch_endpoint() + if switch is not None: + # Ethernet switch fast path: the switch absorbs the peer's + # anchor into its own kernel bridge — no per-link bridge at + # all, so the peer end carries no bridge name (its anchor is + # bridged by the switch; it only keeps its own interface, + # markers it hosts and its carrier). The switch end gets the + # anchor's name to join: the name is the shared naming + # contract (utils.kernel_anchor), a pure function of the + # peer's node type/id/adapter/port — valid whether or not + # the peer is currently running, which is what makes links + # to stopped nodes wireable (the switch joins once the + # anchor exists, re-pushed by node_started). + index = switch[0] + peer_index = 1 - index + peer = self._nodes[peer_index]["node"] + peer_anchor = kernel_anchor_name( + peer.node_type, + peer.id, + self._nodes[peer_index]["adapter_number"], + self._nodes[peer_index]["port_number"], + ) + peer_data = { + "type": "nio_bridge", + "bridge": None, + "filters": node1_filters if peer_index == 0 else node2_filters, + "markers": node1_markers if peer_index == 0 else node2_markers, + "suspend": self._suspended, + } + switch_data = { + "type": "nio_anchor", + "anchor": peer_anchor, + "filters": node1_filters if index == 0 else node2_filters, + "markers": node1_markers if index == 0 else node2_markers, + "suspend": self._suspended, + } + self._link_data = [switch_data, peer_data] if index == 0 else [peer_data, switch_data] + return [ + (node1, adapter_number1, port_number1, self._link_data[0]), + (node2, adapter_number2, port_number2, self._link_data[1]), + ] # Kernel datapath: each endpoint enslaves its veth host end into # a per-link Linux bridge. The name derives from the link id so # both ends compute it independently (no coordinator) and crash @@ -263,8 +339,6 @@ class UDPLink(Link): # the eBPF stateful classifier (frequency_drop, quota) — every # GNS3 filter type has a kernel equivalent now. bridge_name = "gns3" + self._id.replace("-", "")[:11] - node1_filters, node2_filters = self._get_node_filters(node1, node2, kernel=True) - node1_markers, node2_markers = self._get_node_markers(node1, node2) self._link_data = [ { "type": "nio_bridge", @@ -580,6 +654,38 @@ class UDPLink(Link): # explicitly deletes a marker via the REST API, and a marker is torn # down automatically only when its link is deleted. + async def node_started(self, node): + """ + Called after a node of this link reached the started state. + + The Ethernet switch fast path wires itself on the switch side, but a + peer's anchor only comes into existence when the peer starts (and a + peer restart can replace the interface under the same name — e.g. + QEMU recreates its TAPs at every start). The switch cannot observe + that, so the controller re-pushes its NIO: the switch completes a + join it had to defer (or re-joins an anchor that disappeared), + then re-applies the link state it owns. + """ + + if not self._created: + return + switch = self._switch_endpoint() + if switch is None or not self.kernel_datapath: + return + index, switch_node = switch + adapter_number = self._nodes[index]["adapter_number"] + port_number = self._nodes[index]["port_number"] + try: + await switch_node.put( + f"/adapters/{adapter_number}/ports/{port_number}/nio", + data=self._link_data[index], + timeout=120, + ) + except (ComputeError, ControllerError): + # The link keeps its stored NIO; the next node start or link + # update retries. A failure here must not fail the node start. + log.warning("Could not re-push switch NIO for link %s after node start", self.id) + async def start_marker( self, name, diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index 196ada3ab..e66a39a4a 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -101,7 +101,7 @@ from .compute.iou_nodes import IOU, IOUCreate, IOUStart, IOUUpdate from .compute.nat_nodes import NAT, NATCreate, NATUpdate # Compute schemas -from .compute.nios import TAPNIO, UDPNIO, BridgeNIO, BatchNIOCreate, BatchNIOEntry, EthernetNIO, MarkerRebuild, MarkerToggle +from .compute.nios import AnchorNIO, TAPNIO, UDPNIO, BridgeNIO, BatchNIOCreate, BatchNIOEntry, EthernetNIO, MarkerRebuild, MarkerToggle from .compute.qemu_nodes import Qemu, QemuCreate, QemuUpdate from .compute.virtualbox_nodes import VirtualBox, VirtualBoxCreate, VirtualBoxUpdate from .compute.vmware_nodes import VMware, VMwareCreate, VMwareUpdate @@ -164,6 +164,7 @@ __all__ = [ "ApplianceVersion", "ApplianceVersionV8", "AutoIdlePC", + "AnchorNIO", "BatchNIOCreate", "BatchNIOEntry", "BridgeNIO", diff --git a/gns3server/schemas/compute/nios.py b/gns3server/schemas/compute/nios.py index 9946cf755..842682e7c 100644 --- a/gns3server/schemas/compute/nios.py +++ b/gns3server/schemas/compute/nios.py @@ -84,10 +84,18 @@ class BridgeNIO(BaseModel): uBridge reporting cbpf), frequency_drop and quota as the eBPF stateful classifier (needs ebpf). Markers and packet capture are served by uBridge's AF_PACKET modules on the veth host end. + + ``bridge`` may be None: the anchor is then bridged by an Ethernet switch + (its own kernel bridge absorbed it) and the node applies only carrier, + markers and impairments — never bridge membership. """ type: BridgeNIOType - bridge: str = Field(..., description="Kernel bridge name e.g. gns3a1b2c3d4e5") + bridge: Optional[str] = Field( + None, + description="Kernel bridge name e.g. gns3a1b2c3d4e5; None when an Ethernet switch " + "owns the anchor's bridge membership (absorbed link)", + ) suspend: Optional[bool] = Field(None, description="Suspend the NIO") filters: Optional[dict] = Field( None, @@ -97,6 +105,30 @@ class BridgeNIO(BaseModel): markers: Optional[dict] = Field(None, description="Traffic-insight markers (attached to the veth host end)") +class AnchorNIOType(str, Enum): + anchor = "nio_anchor" + + +class AnchorNIO(BaseModel): + """ + Anchor-absorbing Network Input/Output properties, for a node that owns a + kernel bridge (the Ethernet switch): the named foreign interface — the + peer's kernel-datapath anchor — is joined to the node's own bridge with + the node's port settings applied to it. The mirror of BridgeNIO; filters, + markers and capture ride the NIO and attach to the named anchor. + """ + + type: AnchorNIOType + anchor: str = Field(..., description="Host interface to absorb e.g. gv00010203e0p0") + suspend: Optional[bool] = Field(None, description="Suspend the NIO") + filters: Optional[dict] = Field( + None, + description="Packet filters served on the absorbed anchor (netem surface + extensions via one tc netem " + "qdisc, bpf via cls_bpf, frequency_drop/quota via the eBPF classifier)", + ) + markers: Optional[dict] = Field(None, description="Traffic-insight markers (attached to the absorbed anchor)") + + class MarkerToggle(BaseModel): """ Body for the per-marker enable/disable toggle endpoint: flips a running @@ -131,7 +163,7 @@ class BatchNIOEntry(BaseModel): node_id: str = Field(..., description="Node the NIO is attached to") adapter_number: int = Field(0, ge=0, description="Adapter number") port_number: int = Field(0, ge=0, description="Port number") - nio: Union[UDPNIO, BridgeNIO] = Field(..., description="NIO settings") + nio: Union[UDPNIO, BridgeNIO, AnchorNIO] = Field(..., description="NIO settings") class BatchNIOCreate(BaseModel): diff --git a/gns3server/utils/kernel_anchor.py b/gns3server/utils/kernel_anchor.py new file mode 100644 index 000000000..02e380c74 --- /dev/null +++ b/gns3server/utils/kernel_anchor.py @@ -0,0 +1,68 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +The kernel-datapath anchor naming contract, in one place. + +Every node type whose adapters own a host-side interface names that anchor +deterministically: ``g{node_id[:8]}e{adapter}p{port}`` — ``gv`` for a +Docker veth host end, ``gq`` for a QEMU TAP, ``gi`` for an IOU port TAP, +``gd`` for a Dynamips port TAP. The prefixes keep anchors out of the +``gns3`` bridge/TAP name space and name-collision-free on a host; the 8 +hex chars of the node id plus adapter/port keep every name unique and +within IFNAMSIZ (15). + +The compute classes create their anchors with these helpers (single source +of truth), and so does the controller when a switch link has to name the +peer's anchor — the name is a pure function of (node type, node id, +adapter, port), valid whether or not the node is currently running, which +is what makes links to stopped nodes wireable. +""" + +import logging + +log = logging.getLogger(__name__) + +# Node type -> the anchor prefix its interface names carry. ``gc`` (the +# Docker veth guest end) is container-local and deliberately absent. +ANCHOR_PREFIX = { + "docker": "gv", + "qemu": "gq", + "iou": "gi", + "dynamips": "gd", +} + + +def anchor_suffix(node_id, adapter_number, port_number=0): + """ + The shared ``{node_id[:8]}e{adapter}p{port}`` name stem (8 hex chars of + the node id, no dashes). + """ + + return f"{str(node_id).replace('-', '')[:8]}e{adapter_number}p{port_number}" + + +def kernel_anchor_name(node_type, node_id, adapter_number, port_number=0): + """ + The host-side kernel-datapath anchor of one adapter port, or None for a + node type without one (the anchor-less types never anchor a kernel + link). + """ + + prefix = ANCHOR_PREFIX.get(node_type) + if prefix is None: + return None + return prefix + anchor_suffix(node_id, adapter_number, 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 b942876c3..ad5521488 100644 --- a/tests/api/routes/compute/test_ethernet_switch_nodes.py +++ b/tests/api/routes/compute/test_ethernet_switch_nodes.py @@ -14,7 +14,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from unittest.mock import MagicMock, call +import os +from unittest.mock import MagicMock, call, patch import pytest import pytest_asyncio @@ -535,3 +536,507 @@ class TestEthernetSwitchNodesRoutes: ) assert response.status_code == status.HTTP_204_NO_CONTENT node._ubridge_send.assert_any_call(f"bridge stop_capture {relay}") + + # ------------------------------------------------------------------ # + # kernel datapath: absorbed anchors (nio_anchor) + # ------------------------------------------------------------------ # + + @staticmethod + def _anchor_params(anchor: str = "gv00010203e0p0", **extra) -> dict: + params = {"type": "nio_anchor", "anchor": anchor} + params.update(extra) + return params + + @staticmethod + def _sysfs(anchor_exists: bool = True, bridge_member: bool = True): + """An ``os.path.exists`` that answers the /sys/class/net probes of the + kernel-port wiring, delegating everything else to the real one.""" + real = os.path.exists + + def fake(path): + value = str(path) + if "/brif/" in value: + return bridge_member + if value.startswith("/sys/class/net/"): + return anchor_exists + return real(path) + + return patch("os.path.exists", new=fake) + + async def test_ethernet_switch_create_anchor_nio( + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict + ) -> None: + """ + A kernel link joins the peer's anchor straight to the switch bridge + with the port's VLAN mode applied — no relay, no per-port TAP. + """ + + anchor = "gv00010203e0p0" + 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", + ) + with self._sysfs(anchor_exists=True): + response = await compute_client.post(url, json=self._anchor_params(anchor)) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["type"] == "nio_anchor" + + node = compute_project.get_node(ethernet_switch["node_id"]) + br = node._bridge_name + # access VLAN 1 (default): the anchor replaces the port TAP as the + # bridge port, so it carries the port's membership + node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"') + node._ubridge_send.assert_any_call(f'brctl vlan_del "{br}" "{anchor}" 1') + node._ubridge_send.assert_any_call(f'brctl vlan_add "{br}" "{anchor}" 1 pvid untagged') + node._ubridge_send.assert_any_call(f'link set "{anchor}" up') + # no relay was created for this port + assert not any("bridge create" in str(c) for c in node._ubridge_send.call_args_list) + assert node._kernel_ports[0] == anchor + assert node.get_nio(0).anchor == anchor + + async def test_ethernet_switch_create_anchor_nio_defers_when_anchor_missing( + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict + ) -> None: + """ + The peer's anchor only exists while the peer runs: a link created + against a stopped peer must not fail (nor stop the switch's + uBridge) — the join is deferred to the re-push after the peer + starts. + """ + + anchor = "gv00010203e0p0" + 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", + ) + with self._sysfs(anchor_exists=False): + response = await compute_client.post(url, json=self._anchor_params(anchor)) + assert response.status_code == status.HTTP_201_CREATED + + node = compute_project.get_node(ethernet_switch["node_id"]) + assert node._kernel_ports[0] == anchor + assert node.get_nio(0).anchor == anchor + assert not any("addif" in str(c) for c in node._ubridge_send.call_args_list) + assert not any("brctl" in str(c) for c in node._ubridge_send.call_args_list) + + async def test_ethernet_switch_update_anchor_nio_reconciles( + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict + ) -> None: + """ + Updating a joined kernel port reconciles on the anchor: tc + impairments, markers and the carrier the suspend flag drives. + """ + + anchor = "gv00010203e0p0" + 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", + ) + with self._sysfs(anchor_exists=True): + await compute_client.post(url, json=self._anchor_params(anchor)) + + node = compute_project.get_node(ethernet_switch["node_id"]) + node._ubridge_send.reset_mock() + + update_url = app.url_path_for( + "compute:update_ethernet_switch_nio", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + adapter_number="0", + port_number="0", + ) + with self._sysfs(anchor_exists=True, bridge_member=True): + response = await compute_client.put( + update_url, json=self._anchor_params(anchor, filters={"delay": [10]}, suspend=True) + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["suspend"] is True + + node._ubridge_send.assert_any_call(f'tc reset "{anchor}"') + node._ubridge_send.assert_any_call(f'tc netem set "{anchor}" delay 10') + node._ubridge_send.assert_any_call(f'link set "{anchor}" down') + # no re-join on a plain reconcile + assert not any("addif" in str(c) for c in node._ubridge_send.call_args_list) + + async def test_ethernet_switch_update_anchor_nio_completes_deferred_join( + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict + ) -> None: + """ + The controller re-pushes the switch NIO once the peer starts: a join + deferred at link creation completes (the anchor exists but is not a + bridge member), and a peer restart that replaced its anchor under + the same name re-joins. + """ + + anchor = "gv00010203e0p0" + 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", + ) + with self._sysfs(anchor_exists=False): + await compute_client.post(url, json=self._anchor_params(anchor)) + + node = compute_project.get_node(ethernet_switch["node_id"]) + node._ubridge_send.reset_mock() + + update_url = app.url_path_for( + "compute:update_ethernet_switch_nio", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + adapter_number="0", + port_number="0", + ) + # the anchor exists now (peer started) but is not a bridge member + with self._sysfs(anchor_exists=True, bridge_member=False): + response = await compute_client.put(update_url, json=self._anchor_params(anchor)) + assert response.status_code == status.HTTP_201_CREATED + + br = node._bridge_name + node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"') + node._ubridge_send.assert_any_call(f'brctl vlan_add "{br}" "{anchor}" 1 pvid untagged') + node._ubridge_send.assert_any_call(f'link set "{anchor}" up') + + async def test_ethernet_switch_delete_anchor_nio( + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict + ) -> None: + """ + Removing a kernel link resets the anchor's qdisc, takes it out of + the switch bridge and drops its carrier — the anchor itself survives + (it belongs to the peer). + """ + + anchor = "gv00010203e0p0" + 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", + ) + with self._sysfs(anchor_exists=True): + await compute_client.post(url, json=self._anchor_params(anchor)) + + node = compute_project.get_node(ethernet_switch["node_id"]) + node._ubridge_send.reset_mock() + + with self._sysfs(anchor_exists=True): + response = await compute_client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT + + br = node._bridge_name + node._ubridge_send.assert_any_call(f'tc reset "{anchor}"') + node._ubridge_send.assert_any_call(f'brctl delif "{br}" "{anchor}"') + node._ubridge_send.assert_any_call(f'link set "{anchor}" down') + assert 0 not in node._kernel_ports + assert 0 not in node._nios + + async def test_ethernet_switch_capture_anchor_nio( + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict + ) -> None: + """ + Capture on a kernel port runs on the absorbed anchor (AF_PACKET), + not on a relay. + """ + + anchor = "gv00010203e0p0" + 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", + ) + with self._sysfs(anchor_exists=True): + await compute_client.post(url, json=self._anchor_params(anchor)) + + node = compute_project.get_node(ethernet_switch["node_id"]) + node._ubridge_send.reset_mock() + + response = 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"}, + ) + assert response.status_code == status.HTTP_200_OK + assert any( + c.args and str(c.args[0]).startswith(f'capture start_kernel {anchor} "') + for c in node._ubridge_send.call_args_list + ), node._ubridge_send.call_args_list + + 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("capture stop_kernel") + + async def test_ethernet_switch_batch_create_anchor_nio( + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict + ) -> None: + """ + The project-open batch endpoint accepts anchor NIOs (a reopen must + not 422 the switch's kernel links). + """ + + anchor = "gv00010203e0p3" + params = { + "nios": [ + { + "node_id": ethernet_switch["node_id"], + "adapter_number": 0, + "port_number": 3, + "nio": {"type": "nio_anchor", "anchor": anchor}, + } + ] + } + with self._sysfs(anchor_exists=False): + response = await compute_client.post( + app.url_path_for("compute:create_batch_nios", project_id=compute_project.id), json=params + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["added"] == 1 + + node = compute_project.get_node(ethernet_switch["node_id"]) + assert node._kernel_ports[3] == anchor + + # ------------------------------------------------------------------ # + # in-place VLAN reconfiguration (no port ever leaves the bridge) + # ------------------------------------------------------------------ # + + async def test_ethernet_switch_update_ports_reconfigures_in_place( + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict + ) -> None: + """ + Changing a wired port's VLAN edits its membership in place: delete the + old membership, add the new one — the port is never detached from the + bridge (a relay TAP here; the TAP is the cloud/relay-side port). + """ + + node = compute_project.get_node(ethernet_switch["node_id"]) + 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._ubridge_send.reset_mock() + + ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)] + ports[0]["vlan"] = 10 + 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={"ports_mapping": ports}, + ) + assert response.status_code == status.HTTP_200_OK + + br = node._bridge_name + tap = f"{br}-0" + commands = [c.args[0] for c in node._ubridge_send.call_args_list] + assert commands == [ + f'brctl vlan_del "{br}" "{tap}" 1', + f'brctl vlan_add "{br}" "{tap}" 10 pvid untagged', + ], commands + assert not any("delif" in c or "addif" in c for c in commands) + + async def test_ethernet_switch_update_ports_leaves_untouched_ports_alone( + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict + ) -> None: + """ + Only ports whose mode/VLAN changed are touched: re-saving the mapping + no longer re-programs every wired port. + """ + + node = compute_project.get_node(ethernet_switch["node_id"]) + for port_number in (0, 1): + 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=str(port_number), + ) + await compute_client.post(url, json=self._udp_params()) + node._ubridge_send.reset_mock() + + ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)] + ports[1]["vlan"] = 20 + 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={"ports_mapping": ports}, + ) + assert response.status_code == status.HTTP_200_OK + + br = node._bridge_name + commands = [c.args[0] for c in node._ubridge_send.call_args_list] + assert commands == [ + f'brctl vlan_del "{br}" "{br}-1" 1', + f'brctl vlan_add "{br}" "{br}-1" 20 pvid untagged', + ], commands + assert not any(f'"{br}-0"' in c for c in commands) + + async def test_ethernet_switch_vlan_transition_table(self, compute_project: Project, ethernet_switch: dict) -> None: + """ + Every mode transition is expressed as in-place VLAN edits: the old + mode's entries the new mode does not want, then the new mode's + entries. The dot1q admits-all entry is one range operation in both + directions, and same-to-same emits nothing. + """ + + node = compute_project.get_node(ethernet_switch["node_id"]) + br = node._bridge_name + iface = "gvdeadbeef0p0" + + cases = [ + ( + {"type": "access", "vlan": 10}, + {"type": "dot1q", "vlan": 20}, + [ + f'brctl vlan_del "{br}" "{iface}" 10', + f'brctl vlan_add "{br}" "{iface}" 1 vid 4094', + f'brctl vlan_add "{br}" "{iface}" 20 pvid untagged', + ], + ), + ( + {"type": "dot1q", "vlan": 20}, + {"type": "access", "vlan": 30}, + [ + f'brctl vlan_del "{br}" "{iface}" 1 vid 4094', + f'brctl vlan_add "{br}" "{iface}" 30 pvid untagged', + ], + ), + ( + {"type": "dot1q", "vlan": 20}, + {"type": "dot1q", "vlan": 30}, + [ + f'brctl vlan_del "{br}" "{iface}" 20', + f'brctl vlan_add "{br}" "{iface}" 30 pvid untagged', + ], + ), + ( + {"type": "qinq", "vlan": 2}, + {"type": "dot1q", "vlan": 5}, + [ + f'brctl vlan_del "{br}" "{iface}" 2', + f'brctl vlan_add "{br}" "{iface}" 1 vid 4094', + f'brctl vlan_add "{br}" "{iface}" 5 pvid untagged', + ], + ), + ( + {"type": "dot1q", "vlan": 5}, + {"type": "qinq", "vlan": 2}, + [ + f'brctl vlan_del "{br}" "{iface}" 1 vid 4094', + f'brctl vlan_add "{br}" "{iface}" 2 pvid untagged', + ], + ), + ( + {"type": "access", "vlan": 10}, + {"type": "qinq", "vlan": 2}, + [ + f'brctl vlan_del "{br}" "{iface}" 10', + f'brctl vlan_add "{br}" "{iface}" 2 pvid untagged', + ], + ), + ( + {"type": "qinq", "vlan": 2}, + {"type": "access", "vlan": 10}, + [ + f'brctl vlan_del "{br}" "{iface}" 2', + f'brctl vlan_add "{br}" "{iface}" 10 pvid untagged', + ], + ), + ( + {"type": "qinq", "vlan": 2}, + {"type": "qinq", "vlan": 9}, + [ + f'brctl vlan_del "{br}" "{iface}" 2', + f'brctl vlan_add "{br}" "{iface}" 9 pvid untagged', + ], + ), + ( + {"type": "access", "vlan": 7}, + {"type": "access", "vlan": 7}, + [], + ), + ] + for old_settings, new_settings, expected in cases: + node._ubridge_send.reset_mock() + await node._reconfigure_port_vlan(iface, old_settings, new_settings) + commands = [c.args[0] for c in node._ubridge_send.call_args_list] + assert commands == expected, (old_settings, new_settings, commands) + assert not any("delif" in c or "addif" in c for c in commands) + + async def test_ethernet_switch_update_ports_reconfigures_absorbed_anchor_in_place( + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict + ) -> None: + """ + The same in-place reconcile applies to an absorbed kernel anchor (a + peer node's interface): a ports_mapping update issues VLAN edits on + it and never detaches it from the bridge. + """ + + anchor = "gv00010203e0p0" + 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", + ) + with self._sysfs(anchor_exists=True): + await compute_client.post(url, json=self._anchor_params(anchor)) + + node = compute_project.get_node(ethernet_switch["node_id"]) + node._ubridge_send.reset_mock() + + ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)] + ports[0]["vlan"] = 10 + 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={"ports_mapping": ports}, + ) + assert response.status_code == status.HTTP_200_OK + + br = node._bridge_name + commands = [c.args[0] for c in node._ubridge_send.call_args_list] + assert commands == [ + f'brctl vlan_del "{br}" "{anchor}" 1', + f'brctl vlan_add "{br}" "{anchor}" 10 pvid untagged', + ], commands + assert not any("delif" in c or "addif" in c for c in commands) diff --git a/tests/compute/dynamips/test_dynamips_kernel_datapath.py b/tests/compute/dynamips/test_dynamips_kernel_datapath.py index b204f0dc5..b241be526 100644 --- a/tests/compute/dynamips/test_dynamips_kernel_datapath.py +++ b/tests/compute/dynamips/test_dynamips_kernel_datapath.py @@ -612,3 +612,65 @@ async def test_manager_create_nio_builds_the_bridge_nio(router): assert nio.filters == {"delay": [5]} assert nio.suspend is True assert _hypervisor_commands(router) == [] + + +# --------------------------------------------------------------------------- +# Externally bridged anchors (an Ethernet switch absorbed them) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_external_bridge_nio_skips_bridge_membership(router): + """ + A NIOBridge with no bridge means an Ethernet switch owns the anchor's + bridge membership: the router still opens the anchor in the hypervisor + (the TAP needs its fd holder) but never touches any kernel bridge. + """ + + router.status = "started" + router._kernel_taps[(0, 0)] = TAP00 + nio = await _nio(router, bridge=None) + + await router.slot_add_nio_binding(0, 0, nio) + + hypervisor = _hypervisor_commands(router) + assert any(c.startswith("nio create_tap tap-") for c in hypervisor) + assert any(c.startswith('vm slot_add_nio_binding "R1" 0 0 tap-') for c in hypervisor) + assert not any("brctl" in c for c in _ubridge_commands(router)) + + +@pytest.mark.asyncio +async def test_external_bridge_nio_remove_skips_bridge_ops(router): + """ + Detaching an externally bridged anchor releases the hypervisor's fd and + resets the link state, but must not delif or delete any bridge — the + switch owns them, and deleting the switch's bridge would take the whole + switch down. + """ + + router.status = "started" + router._kernel_taps[(0, 0)] = TAP00 + nio = await _nio(router, bridge=None) + await router.slot_add_nio_binding(0, 0, nio) + router._ubridge_send.reset_mock() + + await router.slot_remove_nio_binding(0, 0) + + commands = _ubridge_commands(router) + assert not any("brctl" in c for c in commands), commands + assert any(c == f'tc reset "{TAP00}"' for c in commands) + + +@pytest.mark.asyncio +async def test_remove_kernel_bridges_skips_external_nios(router): + """ + The bridge sweep never deletes a bridge this node does not own. + """ + + router._kernel_taps[(0, 0)] = TAP00 + external = await _nio(router, bridge=None) + router._slots[0].add_nio(0, external) + + await router._remove_kernel_bridges() + + assert not any("brctl" in c for c in _ubridge_commands(router)) diff --git a/tests/controller/test_kernel_datapath_link.py b/tests/controller/test_kernel_datapath_link.py index 5542783e9..1caff1f7e 100644 --- a/tests/controller/test_kernel_datapath_link.py +++ b/tests/controller/test_kernel_datapath_link.py @@ -802,3 +802,131 @@ async def test_available_filters_unconstrained_without_report(project): link, _node1, _node2 = await _kernel_link(project) link._link_data = [{"type": "nio_bridge"}] assert [f["type"] for f in link.available_filters()] == [f["type"] for f in FILTERS] + + +# --------------------------------------------------------------------------- +# Ethernet switch fast path: the switch absorbs the peer's anchor +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_switch_links_select_the_kernel_fast_path(project): + """ + A link with an Ethernet switch endpoint rides the kernel fast path when + the *peer* can anchor — the switch side needs no anchoring capability + (its bridge exists by construction). Switch-to-switch links never + qualify (a kernel interface belongs to exactly one bridge), nor do + anchor-less peers. + """ + + compute = MagicMock() + compute.id = "compute-1" + compute.capabilities = {"ubridge_tap": True, "ubridge_iol_tap": True} + switch = _node(project, compute, "sw1", node_type="ethernet_switch") + + for peer in ( + _node(project, compute, "docker1"), + _node(project, compute, "qemu1", node_type="qemu"), + _node(project, compute, "iou1", node_type="iou"), + _node(project, compute, "r1", node_type="dynamips"), + ): + link = await _link(project, switch, peer) + assert link._kernel_datapath_eligible(switch, peer) is True, peer.node_type + + # a switch peer: cascades need a veth pair between two bridges — relay + switch2 = _node(project, compute, "sw2", node_type="ethernet_switch") + link = await _link(project, switch, switch2) + assert link._kernel_datapath_eligible(switch, switch2) is False + + # an anchor-less peer + vpcs = _node(project, compute, "pc1", node_type="vpcs") + link = await _link(project, switch, vpcs) + assert link._kernel_datapath_eligible(switch, vpcs) is False + + # a peer whose compute cannot anchor stays relay too + compute_no_tap = _tap_capable_compute(tap_support=None) + qemu = _node(project, compute_no_tap, "qemu1", node_type="qemu") + switch3 = _node(project, compute_no_tap, "sw3", node_type="ethernet_switch") + link = await _link(project, switch3, qemu) + assert link._kernel_datapath_eligible(switch3, qemu) is False + + +@pytest.mark.asyncio +async def test_switch_link_prepare_emits_anchor_and_external_bridge(project): + """ + The fast path's wire format: the switch receives ``nio_anchor`` naming + the peer's anchor (the shared naming contract, computable while the + node is stopped), the peer receives ``nio_bridge`` with no bridge — + its anchor is bridged by the switch. The link's tc impairments are + owned by exactly one end (the switch): both would hit the same + interface. + """ + + from gns3server.utils.kernel_anchor import kernel_anchor_name + + compute = MagicMock() + compute.id = "compute-1" + switch = _node(project, compute, "sw1", node_type="ethernet_switch") + peer = _node(project, compute, "docker1") + link = await _link(project, switch, peer) + link._filters = {"delay": [10]} + + entries = await link._prepare() + switch_nio = entries[0][3] + peer_nio = entries[1][3] + + assert switch_nio["type"] == "nio_anchor" + assert switch_nio["anchor"] == kernel_anchor_name("docker", peer.id, 0, 0) + assert switch_nio["filters"] == {"delay": [10]} + assert peer_nio["type"] == "nio_bridge" + assert peer_nio["bridge"] is None + assert peer_nio["filters"] == {} + assert link.kernel_datapath is True + + # the reverse endpoint order must keep index alignment + link = await _link(project, peer, switch) + link._filters = {"delay": [10]} + entries = await link._prepare() + assert entries[0][3]["type"] == "nio_bridge" + assert entries[0][3]["bridge"] is None + assert entries[0][3]["filters"] == {} + assert entries[1][3]["type"] == "nio_anchor" + assert entries[1][3]["filters"] == {"delay": [10]} + + +@pytest.mark.asyncio +async def test_switch_link_repushes_the_switch_nio_on_node_start(project): + """ + The switch cannot observe the peer's anchors coming into existence: the + controller re-pushes the switch's NIO after a node of the link starts, + which completes a deferred join (or re-joins an anchor a peer restart + replaced). Relay links do nothing. + """ + + from tests.utils import AsyncioMagicMock + + compute = MagicMock() + compute.id = "compute-1" + switch = _node(project, compute, "sw1", node_type="ethernet_switch") + peer = _node(project, compute, "docker1") + link = await _link(project, switch, peer) + entries = await link._prepare() + link._created = True + switch.put = AsyncioMagicMock() + + await link.node_started(peer) + switch.put.assert_called_once_with( + "/adapters/0/ports/0/nio", data=entries[0][3], timeout=120 + ) + + # a relay link never re-pushes anything + switch.put.reset_mock() + compute2 = MagicMock() + compute2.id = "compute-1" + relay_switch = _node(project, compute2, "sw2", node_type="ethernet_switch") + vpcs = _node(project, compute2, "pc1", node_type="vpcs") + relay_link = await _link(project, relay_switch, vpcs) + relay_link._created = True + relay_switch.put = AsyncioMagicMock() + await relay_link.node_started(vpcs) + assert not relay_switch.put.called diff --git a/tests/controller/test_node.py b/tests/controller/test_node.py index b80aa50c1..2c634c8f9 100644 --- a/tests/controller/test_node.py +++ b/tests/controller/test_node.py @@ -936,6 +936,22 @@ async def test_start(node, compute): compute.post.assert_called_with(f"/projects/{node.project.id}/vpcs/nodes/{node.id}/start", timeout=240) +@pytest.mark.asyncio +async def test_start_notifies_links(node, compute): + """ + A successful start tells the node's links: the Ethernet-switch fast + path completes a deferred anchor join only then (UDPLink.node_started). + """ + + compute.post = AsyncioMagicMock() + link = AsyncioMagicMock() + link.deferred = False + node.add_link(link) + + await node.start() + assert link.node_started.called + + @pytest.mark.asyncio async def test_start_iou(compute, project, controller): diff --git a/tests/e2e/harness.py b/tests/e2e/harness.py index d1690f2d0..239ce9bb8 100644 --- a/tests/e2e/harness.py +++ b/tests/e2e/harness.py @@ -282,6 +282,12 @@ def live_server(kernel=True): already be configured for it); everything else starts an isolated instance. Relay-mode tests always need an isolated instance — the datapath choice is a server configuration. + + Either way this waits for the controller's local compute to finish + connecting: the connection's ``GET /capabilities`` runs the uBridge + probes (several ubridge spawns, seconds), and until it completes the + controller caches a default dict with every capability None — a test + reading capabilities in that window would wrongly skip itself. """ url = os.environ.get("GNS3_E2E_URL") if url and kernel: @@ -290,8 +296,14 @@ def live_server(kernel=True): os.environ.get("GNS3_E2E_USER", "admin"), os.environ.get("GNS3_E2E_PASSWORD", "admin"), ).login() - return Server(compute) - return _start_isolated(kernel) + server = Server(compute) + else: + server = _start_isolated(kernel) + if not wait_until(lambda: (server.compute.capabilities() or {}).get("version"), timeout=45): + raise AssertionError( + f"{server.compute.url}: the controller's local compute did not report capabilities within 45s" + ) + return server def release(server, project_id, failed=False): diff --git a/tests/e2e/test_ethernet_switch_kernel_datapath.py b/tests/e2e/test_ethernet_switch_kernel_datapath.py new file mode 100644 index 000000000..57a2b8f25 --- /dev/null +++ b/tests/e2e/test_ethernet_switch_kernel_datapath.py @@ -0,0 +1,290 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Live end-to-end test for the Ethernet switch kernel fast path (pytest +marker ``e2e``): a real switch (a Linux kernel bridge) between two real +c7200 routers, driven through the controller REST API and the IOS consoles. +Real ICMP crossing the switch proves the datapath: + +* links created while the routers are stopped report ``kernel_datapath`` + and defer the join; once the routers boot, the controller's re-push + makes the switch join their anchors — the switch bridge's members become + exactly the two router anchors (no relay TAPs anywhere); +* VLAN programming applies to absorbed anchors: different access VLANs + isolate the routers (ping dies), the same VLAN bridges them again; +* a ``delay 100`` filter lands as netem on the absorbed anchor (single + interface, switch-owned) and the measured RTT grows by ~100 ms; +* suspend admin-downs the anchor and kills the traffic; resume restores; +* capture (hosted by the switch side) writes a pcap of ICMP records; +* deleting a link detaches the anchor (which survives — the router owns + it) and re-creating re-joins it; +* a router stop/start keeps the wiring and the traffic; +* deleting the project removes the switch bridge and the anchors. + +``test_ethernet_switch_relay_control`` is the negative control on an +isolated relay-configured instance: the same topology rides the per-port +uBridge relays (the switch bridge's members are its own relay TAPs, never +the routers' anchors) and still pings. +""" + +import time + +import pytest + +from tests.e2e import harness + +pytestmark = pytest.mark.e2e + +# c7200 slot 0 is the fixed IO slot; the Ethernet PA goes in slot 1. +SLOTS = {"slot1": "PA-2FE-TX"} +ETH = (1, 0) + + +def _pick_image(server): + images = server.compute.dynamips_images() + image = next((name for name in images if name.startswith("c7200")), None) + if not image: + pytest.skip(f"no c7200 image available on this server (images: {images[:5]})") + return image + + +def _switch_bridge_name(switch_id): + # see EthernetSwitch._ensure_bridge + return "gns3" + switch_id.replace("-", "")[:6] + + +def _create_topology(compute, pid, image, name): + switch = compute.call( + "POST", + f"/projects/{pid}/nodes", + { + "compute_id": "local", + "name": f"{name}-SW", + "node_type": "ethernet_switch", + "properties": { + "ports_mapping": [ + {"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 1}, + {"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 1}, + ] + }, + }, + ) + r1 = compute.create_dynamips_router(pid, f"{name}-R1", image, SLOTS) + r2 = compute.create_dynamips_router(pid, f"{name}-R2", image, SLOTS) + return switch, r1, r2 + + +def _boot(server, project_id, node_id, timeout=360): + console = harness.dynamips_console(server, project_id, node_id) + console.boot_wait(timeout=timeout) + return console + + +def test_ethernet_switch_kernel_fast_path(): + server = harness.live_server(kernel=True) + compute = server.compute + if compute.capabilities().get("ubridge_tap") is not True: + pytest.skip("this compute's uBridge cannot create persistent TAPs (ubridge_tap != True)") + image = _pick_image(server) + + project = compute.create_project("esw-e2e") + pid = project["project_id"] + sw_bridge = None + try: + switch, r1, r2 = _create_topology(compute, pid, image, "E2E") + switch_id = switch["node_id"] + r1_id, r2_id = r1["node_id"], r2["node_id"] + sw_bridge = _switch_bridge_name(switch_id) + a1 = harness.anchor_name(r1_id, *ETH) + a2 = harness.anchor_name(r2_id, *ETH) + + # The switch bridge exists from the moment the switch node does; a + # link against a stopped router must not fail — the join defers. + assert harness.bridge_members(sw_bridge) == [] + link1 = compute.create_link(pid, (r1_id, *ETH), (switch_id, 0, 0)) + link2 = compute.create_link(pid, (r2_id, *ETH), (switch_id, 0, 1)) + assert link1["kernel_datapath"] is True, link1 + assert link2["kernel_datapath"] is True, link2 + assert harness.bridge_members(sw_bridge) == [] + + print(".. starting routers and waiting for IOS boots") + compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start") + compute.call("POST", f"/projects/{pid}/nodes/{r2_id}/start") + c1 = _boot(server, pid, r1_id) + c2 = _boot(server, pid, r2_id) + + # Deferred joins completed by the node-start re-push: the anchors + # ARE the switch ports now. + assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) == sorted([a1, a2]), timeout=15), ( + harness.bridge_members(sw_bridge) + ) + + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1") + harness.configure_ios(c2, "R2", eth_ip="10.1.1.2") + print(".. both routers configured, pinging through the switch") + baseline = harness.wait_ping(c1, "10.1.1.2") + assert baseline["success"] == 100, baseline["raw"] + + # VLAN programming on absorbed anchors: different access VLANs + # isolate, the same VLAN bridges again. + compute.call( + "PUT", + f"/projects/{pid}/nodes/{switch_id}", + { + "properties": { + "ports_mapping": [ + {"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 10}, + {"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 20}, + ] + } + }, + ) + assert harness.bridge_members(sw_bridge) == sorted([a1, a2]), harness.bridge_members(sw_bridge) + isolated = harness.ping(c1, "10.1.1.2", repeat=3) + assert isolated["success"] == 0, isolated["raw"] + assert harness.bridge_members(sw_bridge) == sorted([a1, a2]), harness.bridge_members(sw_bridge) + compute.call( + "PUT", + f"/projects/{pid}/nodes/{switch_id}", + { + "properties": { + "ports_mapping": [ + {"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 10}, + {"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 10}, + ] + } + }, + ) + rejoined = harness.wait_ping(c1, "10.1.1.2") + assert rejoined["success"] == 100, rejoined["raw"] + + # Single-owned impairment: the filter lands on the absorbed anchor + # (one interface — the switch's), RTT grows by ~100 ms one-way. + compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"filters": {"delay": [100]}}) + assert "netem" in harness.qdiscs(a1), harness.qdiscs(a1) + assert "netem" not in harness.qdiscs(a2), harness.qdiscs(a2) + delayed = harness.wait_ping(c1, "10.1.1.2") + assert delayed["success"] == 100, delayed["raw"] + assert 60 <= delayed["avg"] <= 250, (baseline, delayed) + compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"filters": {}}) + assert "netem" not in harness.qdiscs(a1) + fast = harness.wait_ping(c1, "10.1.1.2") + assert fast["success"] == 100 and fast["avg"] < 50, fast + + # suspend: the anchor admin-downs, the link is dead; resume restores + compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"suspend": True}) + assert not harness.tap_up(a1) + dead = harness.ping(c1, "10.1.1.2", repeat=3) + assert dead["success"] == 0, dead["raw"] + compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"suspend": False}) + assert harness.tap_up(a1) + assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100 + + # capture (the controller hosts it on the switch — always-on local + # builtin): a pcap of ICMP records written off the absorbed anchor + capture = compute.call( + "POST", f"/projects/{pid}/links/{link1['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"} + ) + harness.ping(c1, "10.1.1.2") + time.sleep(1) + compute.call("POST", f"/projects/{pid}/links/{link1['link_id']}/capture/stop") + count, ethertypes = harness.pcap_records(capture["capture_file_path"]) + assert count >= 4, capture["capture_file_path"] + assert "0800" in ethertypes, ethertypes + + # link delete detaches the anchor (it survives — the router owns + # it); re-creating re-joins it + compute.call("DELETE", f"/projects/{pid}/links/{link2['link_id']}") + assert harness.bridge_members(sw_bridge) == [a1] + assert harness.tap_exists(a2) + link2 = compute.create_link(pid, (r2_id, *ETH), (switch_id, 0, 1)) + assert link2["kernel_datapath"] is True, link2 + assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) == sorted([a1, a2]), timeout=10) + assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + + # router stop/start keeps the wiring: a Dynamips stop only halts the + # emulated router — the hypervisor, the anchor and its bridge + # membership all survive (the same stop-is-not-close semantics as + # the per-link bridge datapath) + print(".. stopping and restarting R1") + compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/stop") + assert harness.tap_exists(a1) + assert harness.bridge_members(sw_bridge) == sorted([a1, a2]) + compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start") + c1.close() + c1 = _boot(server, pid, r1_id) + assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) == sorted([a1, a2]), timeout=15) + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1") + assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 + + c1.close() + c2.close() + except BaseException: + harness.release(server, pid, failed=True) + raise + harness.release(server, pid, failed=False) + + # project delete closed everything: switch bridge and anchors gone + assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) is None, timeout=15), sw_bridge + + +def test_ethernet_switch_relay_control(): + """Negative control: the same topology on an isolated relay-configured + instance rides the per-port uBridge relays — the switch bridge's + members are its own relay TAPs, never the routers' anchors — and still + pings.""" + server = harness.live_server(kernel=False) + compute = server.compute + image = _pick_image(server) + + project = compute.create_project("esw-e2e-relay") + pid = project["project_id"] + sw_bridge = None + try: + switch, r1, r2 = _create_topology(compute, pid, image, "E2E") + sw_bridge = _switch_bridge_name(switch["node_id"]) + r1_id, r2_id = r1["node_id"], r2["node_id"] + a1 = harness.anchor_name(r1_id, *ETH) + + link1 = compute.create_link(pid, (r1_id, *ETH), (switch["node_id"], 0, 0)) + link2 = compute.create_link(pid, (r2_id, *ETH), (switch["node_id"], 0, 1)) + assert link1["kernel_datapath"] is False, link1 + assert link2["kernel_datapath"] is False, link2 + + print(".. starting routers and waiting for IOS boots (relay)") + compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start") + compute.call("POST", f"/projects/{pid}/nodes/{r2_id}/start") + c1 = _boot(server, pid, r1_id) + c2 = _boot(server, pid, r2_id) + + members = harness.bridge_members(sw_bridge) + assert members == sorted([f"{sw_bridge}-0", f"{sw_bridge}-1"]), members + assert a1 not in members + + harness.configure_ios(c1, "R1", eth_ip="10.1.1.1") + harness.configure_ios(c2, "R2", eth_ip="10.1.1.2") + relay_ping = harness.wait_ping(c1, "10.1.1.2") + assert relay_ping["success"] == 100, relay_ping["raw"] + + c1.close() + c2.close() + except BaseException: + harness.release(server, pid, failed=True) + raise + harness.release(server, pid, failed=False) + + assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) is None, timeout=15), sw_bridge diff --git a/tests/utils/test_kernel_anchor.py b/tests/utils/test_kernel_anchor.py new file mode 100644 index 000000000..e5d091ec2 --- /dev/null +++ b/tests/utils/test_kernel_anchor.py @@ -0,0 +1,47 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +The kernel-datapath anchor naming contract (shared by the compute classes +that create anchors and the controller that names a peer's anchor for an +Ethernet-switch link). +""" + +from gns3server.utils.kernel_anchor import anchor_suffix, kernel_anchor_name + +NODE_ID = "00010203-0405-0607-0809-0a0b0c0d0e0f" + + +def test_every_node_type_has_its_own_prefix(): + assert kernel_anchor_name("docker", NODE_ID, 0, 0) == "gv00010203e0p0" + assert kernel_anchor_name("qemu", NODE_ID, 1, 2) == "gq00010203e1p2" + assert kernel_anchor_name("iou", NODE_ID, 0, 3) == "gi00010203e0p3" + assert kernel_anchor_name("dynamips", NODE_ID, 6, 48) == "gd00010203e6p48" + + +def test_unknown_node_types_have_no_anchor(): + assert kernel_anchor_name("ethernet_switch", NODE_ID, 0, 0) is None + assert kernel_anchor_name("vpcs", NODE_ID, 0, 0) is None + + +def test_names_fit_ifnamsiz(): + # worst case: WIC port numbers reach 48 with the longest prefix + assert len(kernel_anchor_name("dynamips", NODE_ID, 6, 48)) <= 15 + + +def test_suffix_is_stable_across_the_node_type(): + assert anchor_suffix(NODE_ID, 1, 2) == "00010203e1p2" + assert kernel_anchor_name("docker", NODE_ID, 1, 2) == "gv" + anchor_suffix(NODE_ID, 1, 2)