# # 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); * the L2-anchor spec §E.2 silence window with both routers shut: the absorbed anchors and the switch bridge stay silent for 5 s; * 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 — on both link endpoint orders (peer-first and switch-first; the order decides which side's NIO update runs last and must not decide the outcome); * 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. Every checkpoint runs dual-stack: the routers carry a ULA v6 address next to their v4 one and each checkpoint pings both families (VLAN isolation and suspend kill both; the netem delays and the capture window carry both). """ 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) # the dual-stack half: a ULA mirroring 10.1.1.x R1_IP6 = "fd00:1:1::1" R2_IP6 = "fd00:1:1::2" 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, idlepc=None): 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, idlepc=idlepc) r2 = compute.create_dynamips_router(pid, f"{name}-R2", image, SLOTS, idlepc=idlepc) return switch, r1, r2 def _boot(server, project_id, node_id, timeout=360): console = harness.ios_console(server, project_id, node_id) console.boot_wait(timeout=timeout) return console def _eth_shutdown(console, shut): """Shut/unshut the router's f1/0 — the only legitimate speaker on the segment (IOS's own CDP/keepalives) — for §E.2's silence window; the address stays configured and answers once it is back.""" console.run("conf t") console.run("interface f1/0") console.run("shutdown" if shut else "no shutdown") console.run("end") 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) idlepc = harness.dynamips_idlepc(server, image) project = compute.create_project("esw-e2e") pid = project["project_id"] sw_bridge = None try: switch, r1, r2 = _create_topology(compute, pid, image, "E2E", idlepc) 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 is created peer-first, link2 switch-first: the endpoint order # decides which side's NIO update runs last, and only the switch-first # order used to expose the passive end's tc-reset wipe (its empty-filter # reconcile ran after the switch's apply). Both orders must work. link1 = compute.create_link(pid, (r1_id, *ETH), (switch_id, 0, 0)) link2 = compute.create_link(pid, (switch_id, 0, 1), (r2_id, *ETH)) 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) # Keep NetworkManager's hands off the anchors: on NM hosts its # netdev state machine can release a freshly re-enslaved tap from # the bridge ~60 ms after the join (silently — the port stays up, # the server keeps believing it is attached), which the link # delete/recreate below triggers nondeterministically. The first # join at node start is unaffected; this covers everything after # it. harness.unmanage_from_networkmanager(a1, a2) # 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", eth_ipv6=R1_IP6) harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_ipv6=R2_IP6) print(".. both routers configured, pinging through the switch") baseline = harness.wait_ping(c1, "10.1.1.2") assert baseline["success"] == 100, baseline["raw"] baseline6 = harness.wait_ping(c1, R2_IP6) assert baseline6["success"] == 100, baseline6["raw"] # L2-anchor spec §E.2: with both routers shut, the absorbed anchors # and the switch bridge stay silent for 5 s (the bridge is created # with multicast snooping off precisely so the bridge role can). if harness.l2only_supported(): for console in (c1, c2): _eth_shutdown(console, shut=True) harness.assert_idle_silence(a1, a2, sw_bridge) for console in (c1, c2): _eth_shutdown(console, shut=False) assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 assert harness.wait_ping(c1, R2_IP6, attempts=5)["success"] == 100 else: print(".. uBridge without link l2only: skipping the §E.2 assertion") # L2-anchor spec §E.1: the absorbed anchors and the switch bridge # carry no L3 identity (skipped on a uBridge without `link l2only`), # and the joined ports are FORWARDING — the silent-failure guard. if harness.l2only_supported(): harness.assert_pure_l2(a1) harness.assert_pure_l2(a2) harness.assert_pure_l2(sw_bridge) else: print(".. uBridge without link l2only: skipping the §E.1 assertions") harness.assert_forwarding(a1) harness.assert_forwarding(a2) # 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"] isolated6 = harness.ping(c1, R2_IP6, repeat=3) assert isolated6["success"] == 0, isolated6["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"] rejoined6 = harness.wait_ping(c1, R2_IP6) assert rejoined6["success"] == 100, rejoined6["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) # the v6 twin reads the capture's own timestamps: the guest's # ping ipv6 under-reports a delayed round trip on dynamips (see # test_dynamips_kernel_datapath), and here the single-owned delay # is small enough that the quirk swallows most of the signal capture = compute.call( "POST", f"/projects/{pid}/links/{link1['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"} ) harness.ping(c1, R2_IP6) time.sleep(1) compute.call("POST", f"/projects/{pid}/links/{link1['link_id']}/capture/stop") wire6 = harness.pcap_icmp_rtt(capture["capture_file_path"], "86dd") print(f".. delay 100: v4 {baseline['avg']}->{delayed['avg']} ms, v6 wire {wire6:.0f} ms") assert wire6 is not None and wire6 >= 60, wire6 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"] < harness.FAST_RTT_MS, fast fast6 = harness.wait_ping(c1, R2_IP6) assert fast6["success"] == 100 and fast6["avg"] < harness.FAST_RTT_MS, fast6 # The same single-owned impairment on link2 — created switch-first, # the order whose peer NIO update runs LAST. The passive end's empty # reconcile used to tc-reset the shared anchor right after the switch # applied the netem, wiping it (API green, data plane unimpaired); # the qdisc and the RTT must both hold under this order too. compute.call("PUT", f"/projects/{pid}/links/{link2['link_id']}", {"filters": {"delay": [100]}}) assert "netem" in harness.qdiscs(a2), harness.qdiscs(a2) assert "netem" not in harness.qdiscs(a1), harness.qdiscs(a1) delayed2 = harness.wait_ping(c1, "10.1.1.2") assert delayed2["success"] == 100, delayed2["raw"] assert 60 <= delayed2["avg"] <= 250, (baseline, delayed2) # the capture must sit on the pinging node's anchor (link1's, i.e. # R1's): a TX copy is timestamped post-qdisc, so on link2's own # anchor — where this window's delay impairs the request direction — # the request copy would already carry the delay and the measured # delta would collapse to the turnaround. R1's anchor sees the # request before the delay and the reply after it. capture = compute.call( "POST", f"/projects/{pid}/links/{link1['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"} ) harness.ping(c1, R2_IP6) time.sleep(1) compute.call("POST", f"/projects/{pid}/links/{link1['link_id']}/capture/stop") wire2_6 = harness.pcap_icmp_rtt(capture["capture_file_path"], "86dd") print(f".. delay 100 (link2): v4 {baseline['avg']}->{delayed2['avg']} ms, v6 wire {wire2_6:.0f} ms") assert wire2_6 is not None and wire2_6 >= 60, wire2_6 compute.call("PUT", f"/projects/{pid}/links/{link2['link_id']}", {"filters": {}}) assert "netem" not in harness.qdiscs(a2) fast2 = harness.wait_ping(c1, "10.1.1.2") assert fast2["success"] == 100 and fast2["avg"] < harness.FAST_RTT_MS, fast2 fast2_6 = harness.wait_ping(c1, R2_IP6) assert fast2_6["success"] == 100 and fast2_6["avg"] < harness.FAST_RTT_MS, fast2_6 # 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"] dead6 = harness.ping(c1, R2_IP6, repeat=3) assert dead6["success"] == 0, dead6["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 assert harness.wait_ping(c1, R2_IP6)["success"] == 100 # capture (the controller hosts it on the switch — always-on local # builtin): a pcap of ICMP records written off the absorbed anchor, # both families inside the window 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") harness.ping(c1, R2_IP6) 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 >= 8, capture["capture_file_path"] assert "0800" in ethertypes, ethertypes assert "86dd" 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 assert harness.wait_ping(c1, R2_IP6, 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", eth_ipv6=R1_IP6) assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100 assert harness.wait_ping(c1, R2_IP6, 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) idlepc = harness.dynamips_idlepc(server, image) project = compute.create_project("esw-e2e-relay") pid = project["project_id"] sw_bridge = None try: switch, r1, r2 = _create_topology(compute, pid, image, "E2E", idlepc) 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 if harness.l2only_supported(): # creation-time hardening applies on the relay too: the switch's # own port TAPs and its bridge are pure L2 for member in members: harness.assert_pure_l2(member) harness.assert_pure_l2(sw_bridge) harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_ipv6=R1_IP6) harness.configure_ios(c2, "R2", eth_ip="10.1.1.2", eth_ipv6=R2_IP6) relay_ping = harness.wait_ping(c1, "10.1.1.2") assert relay_ping["success"] == 100, relay_ping["raw"] relay_ping6 = harness.wait_ping(c1, R2_IP6) assert relay_ping6["success"] == 100, relay_ping6["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