feat(esw): wire peer anchors straight into the switch bridge

The Ethernet switch's kernel fast path: instead of a link crossing the
switch's per-port uBridge relay, the switch absorbs the peer's anchor into
its own kernel bridge. The peer's interface (Docker veth host end, QEMU
TAP, IOU port TAP, Dynamips port TAP) becomes the switch port, and frames
cross anchor -> switch bridge -> switch ports entirely in the kernel.

Wire format: the switch end receives nio_anchor ('join this interface to
my own bridge' — the mirror of nio_bridge), the peer end nio_bridge with a
null bridge ('your anchor is bridged elsewhere': carrier, markers and
impairments only — never bridge membership, and never a bridge delete, on
an interface this node does not own). Anchor names come from the shared
naming contract (utils.kernel_anchor), a pure function of node type, id,
adapter and port — the controller computes the peer's anchor while the
node may be stopped, which is what makes links to stopped nodes wireable.

Stopped peers defer the join; after any node of a link starts
(UDPLink.node_started) the controller re-pushes the switch NIO, and the
switch completes — or repairs, when a peer restart replaced its interface
under the same name — the join against the kernel's actual membership
instead of assuming it.

VLAN changes now edit membership in place: only the ports whose mode/VLAN
actually changed are touched, each losing exactly the previous mode's
entries the new mode does not want (the dot1q admits-all entry is one
range operation in both directions), and no port ever leaves the bridge to
change its VLANs. This replaces the old 'reset by re-enslaving' shortcut
(delif+addif on every wired port, added when the ports were the switch's
own TAPs): on absorbed anchors, re-enslaving an interface owned by another
node is where live testing watched links die — anchors occasionally lost
their bridge membership right after a ports_mapping update (later updates
then failed with delif EINVAL), reproducibly with qemu, IOU and Dynamips
peers and never with Docker's veths.

Eligibility: the switch side needs no capability of its own (its bridge
exists by construction); the peer must be able to anchor. Switch-to-switch
cascades stay on the relay — a kernel interface belongs to one bridge, and
joining two bridges needs a veth pair, deferred.

Verified live on a real server: qemu peers through four VLAN cycles with
both anchors enslaved throughout; a mixed switch (2 IOU + 2 Dynamips, four
kernel ports); Docker peers with real ICMP isolating and recovering (with
both anchors still enslaved); VPCS relay ports likewise in place.
This commit is contained in:
YueGuobin committed 2026-10-06 00:21:09 +08:00
1 parent 3910a6b585
commit c343ce0b8f
25 files changed
+1902 -110

No files matched your search

+17 -8
View File
@@ -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
+5
View File
@@ -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):
"""
+108 -2
View File
@@ -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,