mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-30 14:00:12 +03:00
Extend the docker_vm / base_node demotion to the remaining node types
and supporting layers:
qemu_vm: MAC, adapters, disk image, RAM, priority, NIO added, created
iou_vm: application ID, adapters, serial, image, RAM, NIO added
dynamips router: created, adapter, RAM, NVRAM, IOS, idle-PC, disk,
MAC, NIO bound; hypervisor create/start/connect; nio_udp created
builtin: ethernet_switch/hub, cloud, nat — created, NIO bound
ubridge: hypervisor start/connect
At multi-node scale these per-node lines flood the log. Only the
project-open progress summary (loaded N nodes / creating N links)
now remains at INFO alongside genuinely exceptional events.
540 lines
22 KiB
Python
540 lines
22 KiB
Python
#
|
|
# Copyright (C) 2016 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/>.
|
|
|
|
"""
|
|
Ethernet switch backed by a Linux kernel bridge driven through uBridge's
|
|
``brctl`` module.
|
|
|
|
The historical GNS3 Ethernet switch was an emulated L2 device inside Dynamips
|
|
(``ethsw``). This implementation replaces it with a *real* Linux kernel bridge:
|
|
one bridge per switch node, managed over uBridge's hypervisor socket. Each
|
|
switch port is a persistent TAP that plays two roles at once -- uBridge holds
|
|
its file descriptor as a ``nio_tap`` relay endpoint, and the same TAP is
|
|
enslaved to the kernel bridge as a port. This dual-role TAP is exactly the
|
|
pattern the Cloud node already uses for host bridges (see
|
|
``cloud.py::_add_linux_ethernet``).
|
|
|
|
Data path (UDP link mode)::
|
|
|
|
peer --UDP-- ubridge[nio_udp <-> nio_tap(tap)] --tap-- kernel bridge --tap-- ... (other ports)
|
|
|
|
The kernel bridge performs MAC learning/forwarding and VLAN filtering; uBridge
|
|
is only the per-port UDP transport (uBridge is strictly a 2-NIO pipe, it cannot
|
|
be the switch). ESW ``access``/``dot1q``/``qinq`` port modes are composed from
|
|
the ``brctl`` VLAN primitives here -- see ``_apply_port_vlan``.
|
|
"""
|
|
|
|
from ...base_node import BaseNode
|
|
from ...nios.nio_udp import NIOUDP
|
|
from ...error import NodeError
|
|
from gns3server.compute.ubridge.ubridge_error import UbridgeError
|
|
|
|
import logging
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# VLAN ethertypes the Linux kernel bridge can realise. ``brctl setvlanproto``
|
|
# accepts only 0x8100 (802.1Q) and 0x88a8 (802.1ad). The GNS3 schema also allows
|
|
# the legacy 0x9100/0x9200 QinQ ethertypes; the kernel bridge cannot do those, so
|
|
# configuring them on a qinq port is rejected.
|
|
_SUPPORTED_VLAN_ETHERTYPE = {"0x8100", "0x88a8"}
|
|
_QINQ_ETHERTYPE = "0x88a8"
|
|
|
|
|
|
class EthernetSwitch(BaseNode):
|
|
|
|
"""
|
|
Ethernet switch.
|
|
|
|
:param name: name for this switch
|
|
:param node_id: Node identifier
|
|
:param project: Project instance
|
|
:param manager: Parent VM Manager
|
|
:param ports: initial switch ports
|
|
"""
|
|
|
|
def __init__(self, name, node_id, project, manager, console=None, console_type=None, ports=None):
|
|
|
|
super().__init__(name, node_id, project, manager, console=console, console_type=console_type or "none")
|
|
# The switch has no console; ``console_type="none"`` makes BaseNode skip
|
|
# reserving a TCP console port entirely.
|
|
self._ubridge_require_privileged_access = True
|
|
|
|
self._nios = {}
|
|
self._tap_by_port = {} # port_number -> kernel TAP enslaved to 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
|
|
# 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.
|
|
self._started = False
|
|
|
|
if ports is None:
|
|
# 8 access ports in VLAN 1 by default, matching the historical ESW.
|
|
self._ports_mapping = []
|
|
for port_number in range(0, 8):
|
|
self._ports_mapping.append(
|
|
{"port_number": port_number, "name": f"Ethernet{port_number}", "type": "access", "vlan": 1}
|
|
)
|
|
else:
|
|
self._ports_mapping = self._normalize_ports(ports)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# helpers
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@staticmethod
|
|
def _normalize_ports(ports):
|
|
"""Assign sequential port numbers/names like the Dynamips ESW did."""
|
|
port_number = 0
|
|
normalized = []
|
|
for port in ports:
|
|
port = dict(port)
|
|
port["name"] = f"Ethernet{port_number}"
|
|
port["port_number"] = port_number
|
|
normalized.append(port)
|
|
port_number += 1
|
|
return normalized
|
|
|
|
def _ubridge_bridge_name(self, port_number):
|
|
"""Name of the per-port uBridge relay bridge (not a kernel interface)."""
|
|
return f"{self._id}-{port_number}"
|
|
|
|
def _tap_name(self, port_number):
|
|
"""Kernel TAP name for a port: ``<bridge>-<port>`` (host-unique via the bridge)."""
|
|
return f"{self._bridge_name}-{port_number}"
|
|
|
|
def _port_settings(self, port_number):
|
|
for port in self._ports_mapping:
|
|
if port["port_number"] == port_number:
|
|
return port
|
|
return None
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# properties / serialisation
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@property
|
|
def nios(self):
|
|
return self._nios
|
|
|
|
@property
|
|
def ports_mapping(self):
|
|
return self._ports_mapping
|
|
|
|
@ports_mapping.setter
|
|
def ports_mapping(self, ports):
|
|
if ports != self._ports_mapping:
|
|
if len(self._nios) > 0 and len(ports) != len(self._ports_mapping):
|
|
raise NodeError("Cannot change the port count of a switch that is already connected.")
|
|
self._ports_mapping = self._normalize_ports(ports)
|
|
|
|
@property
|
|
def console(self):
|
|
return self._console
|
|
|
|
@console.setter
|
|
def console(self, console):
|
|
self._console = console
|
|
|
|
@property
|
|
def console_type(self):
|
|
return self._console_type
|
|
|
|
@console_type.setter
|
|
def console_type(self, console_type):
|
|
self._console_type = console_type
|
|
|
|
def asdict(self):
|
|
|
|
return {
|
|
"name": self.name,
|
|
"usage": self.usage,
|
|
"node_id": self.id,
|
|
"project_id": self.project.id,
|
|
"ports_mapping": self._ports_mapping,
|
|
"console": self.console,
|
|
"console_type": self.console_type,
|
|
# The switch is always-on once created (a kernel bridge), like the ESW.
|
|
"status": "started",
|
|
}
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# lifecycle
|
|
# ------------------------------------------------------------------ #
|
|
|
|
async def create(self):
|
|
"""
|
|
Creates this switch.
|
|
"""
|
|
|
|
await self.start()
|
|
log.debug(f'Ethernet switch "{self._name}" [{self._id}] has been created')
|
|
|
|
async def start(self):
|
|
"""
|
|
Starts this switch: bring up uBridge, create the kernel bridge, and
|
|
re-wire any ports already bound before a restart.
|
|
"""
|
|
|
|
if not self._started:
|
|
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
|
await self._stop_ubridge()
|
|
await self._start_ubridge(self._ubridge_require_privileged_access)
|
|
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)
|
|
except (UbridgeError, NodeError) as e:
|
|
self._started = False
|
|
raise e
|
|
self._started = True
|
|
|
|
async def _ensure_bridge(self):
|
|
"""
|
|
Creates the per-node kernel bridge once and enables VLAN filtering.
|
|
Applies the bridge-level QinQ ethertype if any port needs it.
|
|
|
|
The bridge name is deterministic: ``gns3`` + the first 6 hex chars of
|
|
this switch's UUID (kernel interface names are ≤ 15 chars). A stale
|
|
bridge from a previous crash is deleted first so ``brctl create`` never
|
|
hits EEXIST.
|
|
"""
|
|
|
|
if self._bridge_created:
|
|
return
|
|
# deterministic short name — 10 chars, always fits the 15-char kernel cap
|
|
self._bridge_name = "gns3" + self._id.replace("-", "")[:6]
|
|
# crash recovery: best-effort delete any leftover bridge
|
|
try:
|
|
await self._ubridge_send(f'brctl delete "{self._bridge_name}"')
|
|
except UbridgeError:
|
|
pass # not found = nothing to clean
|
|
await self._ubridge_send(f'brctl create "{self._bridge_name}"')
|
|
# ``brctl create`` leaves the bridge DOWN; bring it UP so it forwards.
|
|
await self._ubridge_send(f'link set "{self._bridge_name}" up')
|
|
await self._ubridge_send(f'brctl vlanfiltering "{self._bridge_name}" on')
|
|
self._bridge_created = True
|
|
await self._apply_bridge_proto_if_needed()
|
|
|
|
async def _apply_bridge_proto_if_needed(self):
|
|
"""
|
|
If any port is a QinQ port using the 802.1ad ethertype (0x88a8), switch
|
|
the whole bridge to that protocol. A Linux bridge has a single VLAN
|
|
protocol, so mixed QinQ ethertypes within one switch are not supported.
|
|
"""
|
|
|
|
proto = None
|
|
for port in self._ports_mapping:
|
|
if port.get("type") == "qinq":
|
|
# normalise case: the schema carries uppercase (e.g. "0x88A8") but
|
|
# brctl setvlanproto wants lowercase hex
|
|
ethertype = port.get("ethertype", "0x8100").lower()
|
|
if ethertype not in _SUPPORTED_VLAN_ETHERTYPE:
|
|
raise NodeError(
|
|
f"VLAN ethertype {ethertype} is not supported by the Linux bridge "
|
|
f"(only 0x8100/0x88a8) for QinQ port {port['name']}"
|
|
)
|
|
if ethertype == _QINQ_ETHERTYPE:
|
|
proto = _QINQ_ETHERTYPE
|
|
if proto and not self._bridge_proto_set:
|
|
await self._ubridge_send(f'brctl setvlanproto "{self._bridge_name}" {proto}')
|
|
self._bridge_proto_set = True
|
|
|
|
async def delete(self):
|
|
"""
|
|
Deletes this switch.
|
|
"""
|
|
|
|
return await self.close()
|
|
|
|
async def close(self):
|
|
"""
|
|
Closes this switch: release UDP ports, tear down the kernel bridge, stop uBridge.
|
|
"""
|
|
|
|
if not (await super().close()):
|
|
return False
|
|
|
|
for nio in self._nios.values():
|
|
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:
|
|
try:
|
|
# Deleting the bridge releases its enslaved TAPs; uBridge destroys
|
|
# them when it stops below.
|
|
await self._ubridge_send(f'brctl delete "{self._bridge_name}"')
|
|
except UbridgeError as e:
|
|
log.warning(f'Could not delete kernel bridge "{self._bridge_name}": {e}')
|
|
self._bridge_created = False
|
|
self._bridge_proto_set = False
|
|
self._bridge_name = None
|
|
self._started = False
|
|
|
|
await self._stop_ubridge()
|
|
log.debug(f'Ethernet switch "{self._name}" [{self._id}] has been closed')
|
|
return True
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# per-port wiring
|
|
# ------------------------------------------------------------------ #
|
|
|
|
async def add_nio(self, nio, port_number):
|
|
"""
|
|
Adds a NIO as a new port on this switch.
|
|
|
|
: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")
|
|
|
|
log.debug(
|
|
'Ethernet switch "{name}" [{id}]: NIO {nio} bound to port {port}'.format(
|
|
name=self._name, id=self._id, nio=nio, port=port_number
|
|
)
|
|
)
|
|
try:
|
|
await self.start()
|
|
await self._add_ubridge_connection(nio, port_number)
|
|
self._nios[port_number] = nio
|
|
except (NodeError, UbridgeError) as e:
|
|
log.error('Cannot add NIO on Ethernet switch "{name}": {error}'.format(name=self._name, error=e))
|
|
await self._stop_ubridge()
|
|
self.status = "stopped"
|
|
self._nios[port_number] = nio
|
|
self.project.emit("log.error", {"message": str(e)})
|
|
|
|
async def _add_ubridge_connection(self, nio, port_number):
|
|
"""
|
|
Wires one port: a per-port uBridge relay (nio_tap <-> nio_udp) whose TAP
|
|
is enslaved to the kernel bridge, with the port's VLAN mode applied.
|
|
"""
|
|
|
|
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}'")
|
|
|
|
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
|
tap = self._tap_name(port_number)
|
|
|
|
# per-port uBridge relay -- uBridge holds the TAP fd
|
|
await self._ubridge_send(f"bridge create {ubridge_bridge}")
|
|
await self._ubridge_send(f'bridge add_nio_tap {ubridge_bridge} "{tap}"')
|
|
# enslave the same TAP to the kernel bridge (the cloud.py::_add_linux_ethernet move)
|
|
await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{tap}"')
|
|
# VLAN membership for this port's access/trunk/qinq mode
|
|
await self._apply_port_vlan(port_settings, tap)
|
|
# GNS3 link endpoint
|
|
await self._ubridge_send(
|
|
"bridge add_nio_udp {name} {lport} {rhost} {rport}".format(
|
|
name=ubridge_bridge, lport=nio.lport, rhost=nio.rhost, rport=nio.rport
|
|
)
|
|
)
|
|
await self._ubridge_apply_filters(ubridge_bridge, nio.filters)
|
|
await self._ubridge_apply_markers(ubridge_bridge, nio)
|
|
if nio.capturing:
|
|
await self._ubridge_send(
|
|
'bridge start_capture {name} "{output_file}"'.format(
|
|
name=ubridge_bridge, output_file=nio.pcap_output_file
|
|
)
|
|
)
|
|
await self._ubridge_send(f"bridge start {ubridge_bridge}")
|
|
self._tap_by_port[port_number] = tap
|
|
|
|
async def _delete_ubridge_connection(self, port_number):
|
|
"""
|
|
Tears down one port's wiring: release the TAP from the bridge and delete
|
|
the per-port uBridge relay.
|
|
"""
|
|
|
|
tap = self._tap_by_port.pop(port_number, None)
|
|
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
|
if tap is not None and self._bridge_created:
|
|
try:
|
|
await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"')
|
|
except UbridgeError as e:
|
|
log.warning(f'Could not remove TAP "{tap}" from bridge "{self._bridge_name}": {e}')
|
|
try:
|
|
await self._ubridge_send(f"bridge delete {ubridge_bridge}")
|
|
except UbridgeError as e:
|
|
log.warning(f"Could not delete uBridge bridge {ubridge_bridge}: {e}")
|
|
|
|
async def remove_nio(self, port_number):
|
|
"""
|
|
Removes the specified NIO from this switch.
|
|
|
|
:param port_number: allocated port number
|
|
:returns: the NIO that was bound to the allocated port
|
|
"""
|
|
|
|
if port_number not in self._nios:
|
|
raise NodeError(f"Port {port_number} is not allocated")
|
|
|
|
await self.stop_capture(port_number)
|
|
nio = self._nios[port_number]
|
|
if isinstance(nio, NIOUDP):
|
|
self.manager.port_manager.release_udp_port(nio.lport, self._project)
|
|
|
|
log.debug(
|
|
'Ethernet switch "{name}" [{id}]: NIO {nio} removed from port {port}'.format(
|
|
name=self._name, id=self._id, nio=nio, 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)
|
|
return nio
|
|
|
|
def get_nio(self, port_number):
|
|
"""
|
|
Gets a port NIO binding.
|
|
|
|
:param port_number: port number
|
|
:returns: NIO instance
|
|
"""
|
|
|
|
if port_number not in self._nios:
|
|
raise NodeError(f"Port {port_number} is not connected")
|
|
return self._nios[port_number]
|
|
|
|
async def update_nio(self, port_number, nio):
|
|
"""
|
|
Re-applies uBridge filters/markers for a port (called when a link is updated).
|
|
"""
|
|
|
|
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)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# VLAN mode translation
|
|
# ------------------------------------------------------------------ #
|
|
|
|
async def _reset_port_vlan(self, tap):
|
|
"""
|
|
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.
|
|
"""
|
|
|
|
await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"')
|
|
await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{tap}"')
|
|
|
|
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.
|
|
|
|
- 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
|
|
the native VLAN PVID + untagged. (The ESW model declares only the native
|
|
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.
|
|
"""
|
|
|
|
br = self._bridge_name
|
|
port_type = port_settings["type"]
|
|
vlan = int(port_settings["vlan"])
|
|
|
|
if port_type == "access":
|
|
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
|
|
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
|
|
elif port_type == "dot1q":
|
|
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
|
|
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" 1 vid 4094')
|
|
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
|
|
elif port_type == "qinq":
|
|
# setvlanproto is applied at the bridge level by _apply_bridge_proto_if_needed
|
|
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
|
|
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
|
|
else:
|
|
raise NodeError(f"Unknown port type '{port_type}' on Ethernet switch '{self.name}'")
|
|
|
|
async def update_port_settings(self):
|
|
"""
|
|
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.
|
|
"""
|
|
|
|
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:
|
|
continue
|
|
await self._reset_port_vlan(tap)
|
|
await self._apply_port_vlan(port_settings, tap)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# capture
|
|
# ------------------------------------------------------------------ #
|
|
|
|
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).
|
|
|
|
:param port_number: allocated port number
|
|
:param output_file: PCAP destination file for the capture
|
|
:param data_link_type: PCAP data link type (DLT_*), default is DLT_EN10MB
|
|
"""
|
|
|
|
nio = self.get_nio(port_number)
|
|
if nio.capturing:
|
|
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(
|
|
'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):
|
|
"""
|
|
Stops a packet capture on a port.
|
|
|
|
:param port_number: allocated port number
|
|
"""
|
|
|
|
nio = self.get_nio(port_number)
|
|
if not nio.capturing:
|
|
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(
|
|
'Ethernet switch "{name}" [{id}]: stopping packet capture on port {port}'.format(
|
|
name=self.name, id=self.id, port=port_number
|
|
)
|
|
)
|