gns3-server/tests/e2e/test_ethernet_switch_kernel_datapath.py
YueGuobin 6459122113
test(e2e): run every traffic checkpoint dual-stack (IPv6)
The live suite asserted the datapath over IPv4 only. Give every guest a
ULA IPv6 address next to its v4 one and twin every v4 assertion point,
sharing the impairment window (one filter application, both families
asserted) so the runtime grows by minutes, not double.

- harness: configure_ios(eth_ipv6=), ping() dispatches to `ping ipv6`,
  docker_bring_up_ipv6 (the Docker daemon leaves eth0 with
  disable_ipv6=1 on v6-less bridge networks, so the interfaces-file
  route cannot work), family-aware docker_wait_address, pcap_records
  scans every record (a leading DAD/MLD frame must not mask the
  sampled ethertype), and pcap_icmp_rtt measures the wire round trip
  from capture timestamps;
- scenarios: connectivity/delay/suspend/isolation/churn checks in both
  families, captures assert 86dd next to 0800, the libpcap bpf drops
  are asserted family-selective (icmp vs icmp6, each killing its own
  family and leaving the other alive), the eBPF classifier modes on
  both families, marker v6 twins with an echo-specific expression
  (bare icmp6 also matches DAD/MLD and would break the exact counts),
  the v6 sharkd replay, and the link-local matrix gains the 86dd
  all-nodes row; serial (HDLC) legs stay v4-only;
- the dynamips-emulated classic IOS under-reports its `ping ipv6` RTT
  by ~40 % of a delayed round trip (pcap-verified: the wire carries the
  v6 echo exchange in the same ~230 ms as v4 while the console reports
  ~130 ms), so the dynamips-family scenarios assert the v6 delay from
  the capture's own timestamps — recorded in tests/e2e/README.md.

Full suite: 17 passed in 58m50s.
2026-10-07 13:19:38 +08:00

424 lines
20 KiB
Python

#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
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