From cb7f74c77b71cd6965f37ebb0a13588db4b36e21 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 2 Oct 2026 21:49:14 +0800 Subject: [PATCH] fix(esw): harden the cascade end the create race leaves unhardened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker create_veth hardens only its FIRST end (the Docker shape: host anchor plus a container leg that keeps its L3 life). A cascade end is that second, unhardened end whenever the peer switch's create won the race, and the kernel gives it an IPv6 link-local — a silent L3 identity on the switch fabric (ubridge-l2-anchor-spec §E). Each switch now hardens its OWN end with 'link l2only on' once the pair exists, whatever branch brought it there (fresh create, lost race, reuse); idempotent, and best-effort with a warning on a uBridge without the command. --- .../compute/builtin/nodes/ethernet_switch.py | 16 ++++++++++ .../compute/test_ethernet_switch_nodes.py | 31 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/gns3server/compute/builtin/nodes/ethernet_switch.py b/gns3server/compute/builtin/nodes/ethernet_switch.py index d0fd0a478..2b9dab4d0 100644 --- a/gns3server/compute/builtin/nodes/ethernet_switch.py +++ b/gns3server/compute/builtin/nodes/ethernet_switch.py @@ -476,6 +476,22 @@ class EthernetSwitch(KernelDatapathMixin, BaseNode): else: with contextlib.suppress(UbridgeError): await self._ubridge_send(f'tc reset "{anchor}"') + # ``docker create_veth`` hardens only its FIRST end (the Docker + # shape: host anchor plus a container leg that keeps its L3 + # life). Here BOTH ends are anchors, and whichever switch loses + # the create race owns the second, unhardened end — its kernel + # IPv6 link-local would give the switch fabric a silent L3 + # identity (ubridge-l2-anchor-spec §E). Each switch hardens its + # OWN end once the pair exists; idempotent, and it covers a + # reused pre-existing end as well. + try: + await self._ubridge_send(f'link l2only "{anchor}" on') + except UbridgeError as e: + log.warning( + 'Ethernet switch "{name}" [{id}]: could not harden cascade end "{anchor}" L2-only: {error}'.format( + name=self._name, id=self._id, anchor=anchor, error=e + ) + ) # Register before _kernel_attach: the marker reconcile recognises # kernel anchors through _kernel_anchors(). self._kernel_ports[port_number] = anchor diff --git a/tests/api/routes/compute/test_ethernet_switch_nodes.py b/tests/api/routes/compute/test_ethernet_switch_nodes.py index 215c1d9ad..b685b6a0d 100644 --- a/tests/api/routes/compute/test_ethernet_switch_nodes.py +++ b/tests/api/routes/compute/test_ethernet_switch_nodes.py @@ -956,6 +956,37 @@ class TestEthernetSwitchNodesRoutes: node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"') assert node._kernel_ports[0] == anchor + async def test_ethernet_switch_create_cascade_nio_hardens_own_end_l2only( + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict + ) -> None: + """ + ``docker create_veth`` hardens only its FIRST end (the Docker shape: + host anchor plus a container leg that keeps its L3 life) — but a + cascade end the peer's winning create minted is that second, + unhardened end, and the kernel gives it an IPv6 link-local (a silent + L3 identity on the switch fabric, ubridge-l2-anchor-spec §E). Each + switch therefore hardens its OWN end once the pair exists, whatever + branch brought it there (fresh create, lost race, reuse). + """ + + anchor, peer = "gs1a2b3c4d5e0", "gs1a2b3c4d5e1" + 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", + ) + # pre-existing end: the branch whose end create_veth did NOT harden + with self._cascade_sysfs(anchor, {"value": True}): + response = await compute_client.post(url, json=self._cascade_params(anchor, peer)) + assert response.status_code == status.HTTP_201_CREATED + + node = compute_project.get_node(ethernet_switch["node_id"]) + node._ubridge_send.assert_any_call(f'link l2only "{anchor}" on') + # only its own end — the peer's end is the peer switch's to harden + assert not any(f'"{peer}"' in str(c) and "l2only" in str(c) for c in node._ubridge_send.call_args_list) + async def test_ethernet_switch_delete_cascade_nio_destroys_the_pair( self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict ) -> None: