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

+9
View File
@@ -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):
@@ -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
)
)
@@ -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)
+6 -6
View File
@@ -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):
"""
+5 -5
View File
@@ -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):
"""
+28 -11
View File
@@ -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}"')
+84
View File
@@ -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 <http://www.gnu.org/licenses/>.
#
"""
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,
}
+9 -3
View File
@@ -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
"""