Merge remote-tracking branch 'origin/3.1' into 3.1

This commit is contained in:
grossmj committed 2026-07-21 17:04:35 +02:00
commit 0091f2c64a
63 files changed
+3685 -382

No files matched your search

+1
View File
@@ -356,6 +356,7 @@ class BaseManager:
raise ComputeError(f"Could not create an UDP connection to {rhost}:{rport}: {e}")
nio = NIOUDP(lport, rhost, rport)
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"]
+119
View File
@@ -935,9 +935,46 @@ class BaseNode:
f"Hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port} has successfully started"
)
await self._ubridge_hypervisor.connect()
# Tell this uBridge where to send MARK signals and which node id to
# tag them with. Marker is opt-in and inert until a `mark` filter is
# added, so this never disturbs the data plane.
await self._ubridge_configure_marker_sink()
# save if privileged are required in case uBridge needs to be restarted in self._ubridge_send()
self._ubridge_require_privileged_access = require_privileged_access
async def _ubridge_configure_marker_sink(self):
"""
Point this node's uBridge at the compute's marker UDP sink and tag its
signals with this node's id. Safe to call before any marker filter
exists — uBridge stays inert until a ``mark`` filter is configured.
Old uBridge builds without the marker module are tolerated: the failure
is downgraded to a warning so node start is not blocked by an opt-in
observability feature.
"""
from gns3server.compute.marker.marker_manager import MarkerManager
manager = MarkerManager.instance()
if not manager.running or not manager.host or not manager.port:
return
if self._ubridge_hypervisor is None:
return
try:
# Talk to the hypervisor directly, NOT via _ubridge_send: this runs
# inside _start_ubridge, which is reached THROUGH _ubridge_send when
# uBridge starts lazily (e.g. linking a stopped node). _ubridge_send's
# lock is non-reentrant, so calling it again here would deadlock on
# the held ___ubridge_send_lock. uBridge is already running and
# connected at this point, so the raw hypervisor send is safe.
await self._ubridge_hypervisor.send(f"marker sink {manager.host} {manager.port}")
await self._ubridge_hypervisor.send(f"marker node {self._id}")
except UbridgeError:
log.warning(
"uBridge does not support the marker module; traffic insight disabled for node %r",
self.name,
)
async def _stop_ubridge(self):
"""
Stops uBridge.
@@ -983,10 +1020,12 @@ class BaseNode:
await self._ubridge_send(f"bridge start {bridge_name}")
await self._ubridge_apply_filters(bridge_name, destination_nio.filters)
await self._ubridge_apply_markers(bridge_name, destination_nio)
async def update_ubridge_udp_connection(self, bridge_name, source_nio, destination_nio):
if destination_nio:
await self._ubridge_apply_filters(bridge_name, destination_nio.filters)
await self._ubridge_apply_markers(bridge_name, destination_nio)
async def ubridge_delete_bridge(self, name):
"""
@@ -1042,6 +1081,86 @@ class BaseNode:
)
i += 1
async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None):
"""
Attach a `mark` packet filter to a uBridge bridge for traffic insight.
On BPF match uBridge (a) emits a UDP MARK signal to the configured sink
and (b) appends the packet to ``pcap_path``. Unlike the impairment
filters, this is an observability tap: it never drops or alters traffic,
and it is added/removed on its own (not via reset_packet_filters) so the
pcap is not closed/reopened on unrelated filter changes.
:param bridge_name: uBridge bridge carrying the link's traffic
:param name: stable, gns3server-chosen filter name (pcap identity + echoed in signals)
:param bpf: libpcap BPF expression
:param pcap_path: absolute path ubridge appends matched packets to
:param tag: optional correlation id echoed in MARK signals
"""
# mark <bpf> [tag <id>] [pcap <path>] — tag/pcap keyword pairs, any order.
# name travels from the controller REST layer (MarkerCreate schema) but is
# validated here too as defense-in-depth against hand-edited topology files.
# Note: "global-*" names are legitimate here — they come from project-level
# marker definitions (inherit_marker). The prefix is only forbidden at the
# user-facing schema layer, not at the uBridge boundary.
_MARKER_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
if not _MARKER_NAME_RE.match(name):
raise UbridgeError(f"Invalid marker name: {name!r}")
cmd = 'bridge add_packet_filter {bridge} {name} mark "{bpf}"'.format(
bridge=bridge_name, name=name, bpf=bpf
)
if tag is not None:
cmd += f" tag {tag}"
# Per-link attribution (contract §3.2): when one ubridge bridge serves
# several GNS3 links (e.g. IOU's per-node bridge), bridge+filter collide,
# so the link id is the only way to tell signals — and pcap files — apart.
if link_id:
cmd += f" link {link_id}"
cmd += ' pcap "{path}"'.format(path=pcap_path)
# Let BPF compile errors propagate — the marker is the user's intent, so a
# bad expression must surface instead of being silently dropped.
await self._ubridge_send(cmd)
async def _ubridge_apply_markers(self, bridge_name, nio):
"""
(Re-)apply every traffic-insight marker carried by *nio* to the uBridge
bridge *bridge_name*. Called from ``add_ubridge_udp_connection`` (bridge
creation / node restart) and ``update_ubridge_udp_connection`` (NIO update
— the preceding ``_ubridge_apply_filters`` has already issued
``reset_packet_filters``, so we must re-add markers to survive the reset).
"""
from gns3server.compute.marker.marker_manager import MarkerManager
markers = nio.markers if hasattr(nio, 'markers') else {}
if not markers:
return
manager = MarkerManager.instance()
markers_dir = self.project.markers_working_directory()
for name, spec in markers.items():
bpf = spec.get("bpf", "")
tag = spec.get("tag")
link_id = spec.get("link_id", "")
pcap_path = os.path.join(
markers_dir, f"{self._id}_{link_id}_{name}.pcap"
)
try:
await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag, link_id)
except UbridgeError as e:
# Swallow BPF compile errors (warn + skip) so a single bad
# expression can't break link creation / node restart — mirrors
# _ubridge_apply_filters, which does the same for packet filters.
if "syntax error" in str(e).lower() or "compile filter" in str(e).lower():
message = f"Warning: ignoring marker '{name}' due to BPF syntax error: {e}"
log.warning(message)
self.project.emit("log.warning", {"message": message})
continue
raise
manager.register(
str(self.project.id), self._id, name, link_id, tag
)
async def _add_ubridge_ethernet_connection(self, bridge_name, ethernet_interface, block_host_traffic=False):
"""
Creates a connection with an Ethernet interface in uBridge.
@@ -82,6 +82,9 @@ class Cloud(BaseNode):
host_interfaces = []
network_interfaces = gns3server.utils.interfaces.interfaces()
for interface in network_interfaces:
# Hide GNS3 internal bridges (e.g. EthernetSwitch kernel bridges)
if interface["name"].lower().startswith("gns3"):
continue
host_interfaces.append(
{
"name": interface["name"],
@@ -312,6 +315,7 @@ class Cloud(BaseNode):
)
await self._ubridge_apply_filters(bridge_name, nio.filters)
await self._ubridge_apply_markers(bridge_name, nio)
if port_info["type"] in ("ethernet", "tap"):
if not self.manager.has_privileged_access(self.ubridge_path):
@@ -452,6 +456,7 @@ class Cloud(BaseNode):
bridge_name = f"{self._id}-{port_number}"
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
await self._ubridge_apply_filters(bridge_name, nio.filters)
await self._ubridge_apply_markers(bridge_name, nio)
async def _delete_ubridge_connection(self, port_number):
"""
@@ -14,14 +14,45 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
"""
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):
@@ -32,11 +63,101 @@ class EthernetSwitch(BaseNode):
: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):
def __init__(self, name, node_id, project, manager, console=None, console_type=None, ports=None):
super().__init__(name, node_id, project, manager)
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):
@@ -44,61 +165,375 @@ class EthernetSwitch(BaseNode):
"name": self.name,
"usage": self.usage,
"node_id": self.id,
"project_id": self.project.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.
"""
super().create()
await self.start()
log.info(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.
"""
raise NotImplementedError()
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.info(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 new port on this switch.
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
"""
raise NotImplementedError()
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.info(
'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 as member of this switch.
Removes the specified NIO from this switch.
:param port_number: allocated port number
:returns: the NIO that was bound to the allocated port
"""
raise NotImplementedError()
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.info(
'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.
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
"""
raise NotImplementedError()
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.info(
'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.
Stops a packet capture on a port.
:param port_number: allocated port number
"""
raise NotImplementedError()
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.info(
'Ethernet switch "{name}" [{id}]: stopping packet capture on port {port}'.format(
name=self.name, id=self.id, port=port_number
)
)
+18
View File
@@ -87,6 +87,23 @@ class Nat(Cloud):
return True
def asdict(self):
nat_interface = self._ports_mapping[0].get("interface", "") if self._ports_mapping else ""
host_interfaces = []
network_interfaces = gns3server.utils.interfaces.interfaces()
for interface in network_interfaces:
if interface["name"] == nat_interface:
host_interfaces.append(
{
"name": interface["name"],
"type": interface["type"],
"special": interface["special"],
"ip_addresses": interface.get("ip_addresses", []),
}
)
break
return {
"name": self.name,
"usage": self.usage,
@@ -94,4 +111,5 @@ class Nat(Cloud):
"project_id": self.project.id,
"status": "started",
"ports_mapping": self.ports_mapping,
"interfaces": host_interfaces,
}
+2 -1
View File
@@ -1228,6 +1228,7 @@ class DockerVM(BaseNode):
)
await self._ubridge_send(f"bridge start {bridge_name}")
await self._ubridge_apply_filters(bridge_name, nio.filters)
await self._ubridge_apply_markers(bridge_name, nio)
async def adapter_add_nio_binding(self, adapter_number, nio):
"""
@@ -1268,7 +1269,7 @@ class DockerVM(BaseNode):
bridge_name = f"bridge{adapter_number}"
if bridge_name in self._bridges:
await self._ubridge_apply_filters(bridge_name, nio.filters)
await self._ubridge_apply_markers(bridge_name, nio)
async def adapter_remove_nio_binding(self, adapter_number):
"""
Removes an adapter NIO binding.
+1
View File
@@ -376,6 +376,7 @@ class Dynamips(BaseManager):
raise DynamipsError(f"Could not create an UDP connection to {rhost}:{rport}: {e}")
nio = NIOUDP(node, lport, rhost, rport)
nio.filters = nio_settings.get("filters", {})
nio.markers = nio_settings.get("markers", {})
nio.suspend = nio_settings.get("suspend", False)
elif nio_settings["type"] == "nio_generic_ethernet":
ethernet_device = nio_settings["ethernet_device"]
+21
View File
@@ -40,6 +40,7 @@ class NIO:
self._hypervisor = hypervisor
self._name = name
self._filters = {}
self._markers = {}
self._suspended = False
self._capturing = False
self._pcap_output_file = ""
@@ -303,6 +304,26 @@ class NIO:
assert isinstance(new_filters, dict)
self._filters = new_filters
@property
def markers(self):
"""
Returns the list of traffic-insight markers for this NIO.
:returns: markers (dictionary)
"""
return self._markers
@markers.setter
def markers(self, new_markers):
"""
Set markers for this NIO.
:param new_markers: markers (dictionary)
"""
self._markers = new_markers
@property
def capturing(self):
"""
@@ -82,10 +82,12 @@ class NIOUDP(NIO):
self._source_nio = nio_udp.NIOUDP(self._local_tunnel_rport, "127.0.0.1", self._local_tunnel_lport)
self._destination_nio = nio_udp.NIOUDP(self._lport, self._rhost, self._rport)
self._destination_nio.filters = self._filters
self._destination_nio.markers = self._markers
await self._node.add_ubridge_udp_connection(self._bridge_name, self._source_nio, self._destination_nio)
async def update(self):
self._destination_nio.filters = self._filters
self._destination_nio.markers = self._markers
await self._node.update_ubridge_udp_connection(self._bridge_name, self._source_nio, self._destination_nio)
async def close(self):
+61
View File
@@ -746,6 +746,7 @@ class IOUVM(BaseNode):
)
await self._ubridge_apply_filters(bay_id, unit_id, nio.filters)
await self._ubridge_apply_markers(bay_id, unit_id, nio)
unit_id += 1
bay_id += 1
@@ -1067,6 +1068,7 @@ class IOUVM(BaseNode):
)
)
await self._ubridge_apply_filters(adapter_number, port_number, nio.filters)
await self._ubridge_apply_markers(adapter_number, port_number, nio)
async def adapter_update_nio_binding(self, adapter_number, port_number, nio):
"""
@@ -1079,6 +1081,7 @@ class IOUVM(BaseNode):
if self.ubridge:
await self._ubridge_apply_filters(adapter_number, port_number, nio.filters)
await self._ubridge_apply_markers(adapter_number, port_number, nio)
async def _ubridge_apply_filters(self, adapter_number, port_number, filters):
"""
@@ -1095,6 +1098,64 @@ class IOUVM(BaseNode):
cmd = "iol_bridge add_packet_filter {} {}".format(location, filter)
await self._ubridge_send(cmd)
async def _ubridge_apply_markers(self, adapter_number, port_number, nio):
"""
(Re-)apply traffic-insight markers to the IOL bridge.
IOU uses ``iol_bridge`` (not ``bridge``) and the ``add_packet_filter``
command carries extra ``{bay} {unit}`` positional arguments between the
bridge name and the filter name — this override mirrors the pattern in
``_ubridge_apply_filters`` above.
:param adapter_number: bay id
:param port_number: unit id
:param nio: NIO instance carrying ``nio.markers``
"""
from gns3server.compute.marker.marker_manager import MarkerManager
markers = nio.markers if hasattr(nio, 'markers') else {}
if not markers:
return
manager = MarkerManager.instance()
markers_dir = self.project.markers_working_directory()
bridge_name = f"IOL-BRIDGE-{self.application_id + 512}"
location = "{bridge_name} {bay} {unit}".format(
bridge_name=bridge_name, bay=adapter_number, unit=port_number
)
for name, spec in markers.items():
bpf = spec.get("bpf", "")
tag = spec.get("tag")
link_id = spec.get("link_id", "")
pcap_path = os.path.join(
markers_dir, f"{self._id}_{link_id}_{name}.pcap"
)
# Build the iol_bridge marker filter command:
# iol_bridge add_packet_filter {br} {bay} {unit} {name} mark "{bpf}" [tag {id}] pcap "{path}"
cmd = 'iol_bridge add_packet_filter {loc} {name} mark "{bpf}"'.format(
loc=location, name=name, bpf=bpf
)
if tag is not None:
cmd += f" tag {tag}"
# IOU uses one per-node IOL-BRIDGE for every link, so bridge+filter
# are identical across this node's links — `link` is the only way the
# controller can tell their signals apart (contract §3.2).
if link_id:
cmd += f" link {link_id}"
cmd += ' pcap "{path}"'.format(path=pcap_path)
try:
await self._ubridge_send(cmd)
except UbridgeError as e:
if "syntax error" in str(e).lower() or "compile filter" in str(e).lower():
message = f"Warning: ignoring marker '{name}' due to BPF syntax error: {e}"
log.warning(message)
self.project.emit("log.warning", {"message": message})
continue
raise
manager.register(
str(self.project.id), self._id, name, link_id, tag
)
async def adapter_remove_nio_binding(self, adapter_number, port_number):
"""
Removes an adapter NIO binding.
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python
#
# Copyright (C) 2024 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/>.
#
#
# Traffic-insight marker subsystem (compute side).
#
# ubridge's ``marker`` module is a passive tap: on a BPF match it emits a UDP
# ``MARK`` signal to a configured sink and/or appends the packet to a pcap.
# This package owns the compute-side UDP sink: one listener per compute process
# serves every ubridge on that host, disambiguated by ``node=<id>``.
@@ -0,0 +1,109 @@
#!/usr/bin/env python
#
# Copyright (C) 2024 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/>.
import asyncio
import logging
log = logging.getLogger(__name__)
class MarkerListener(asyncio.DatagramProtocol):
"""
Receives ubridge ``MARK`` signal datagrams and turns each into a
``marker.match`` notification.
Signal format (one datagram per match, ASCII)::
MARK <sec.usec> node=<id> filter=<name> tag=<tag> len=<n>\\n
The signal carries metadata only (no packet bytes). The compute-side
:class:`~gns3server.compute.marker.marker_manager.MarkerManager` registry
resolves ``(node_id, filter_name)`` to ``(project_id, link_id, tag)`` so the
event can be emitted on the right project-scoped notification stream.
"""
def __init__(self, manager):
# MarkerManager owns this listener and the registry.
self._manager = manager
self.transport = None
def connection_made(self, transport):
self.transport = transport
def datagram_received(self, data, addr):
try:
self._handle(data)
except Exception:
# Never let a malformed datagram kill the listener.
log.exception("Failed to process MARK datagram from %s: %r", addr, data)
def _handle(self, data):
line = data.decode("utf-8", errors="replace").strip()
if not line.startswith("MARK"):
return
parts = line.split()
# parts[0] == "MARK"; parts[1] == "<sec.usec>"
if len(parts) < 2:
return
try:
ts = float(parts[1])
except ValueError:
log.warning("Ignoring MARK signal with bad timestamp: %r", line)
return
kv = {}
for token in parts[2:]:
if "=" in token:
key, value = token.split("=", 1)
kv[key] = value
node_id = kv.get("node")
filter_name = kv.get("filter")
if not node_id or not filter_name:
return
# "-" means the field was unset on the ubridge side (see contract §3.3).
link = kv.get("link")
tag = kv.get("tag")
length = kv.get("len")
project_id, link_id, registered_tag = self._manager.lookup(node_id, filter_name)
if project_id is None:
log.warning(
"MARK signal for unregistered node=%s filter=%s, dropping", node_id, filter_name
)
return
# `link=` is the authoritative per-link id (opaque, set by gns3server at
# filter install time). It disambiguates signals that share a node+filter
# across several links; fall back to the registry's link only for legacy
# signals that carry no `link=`.
signal_link = link if link and link != "-" else None
event = {
"project_id": project_id,
"node_id": node_id,
"link_id": signal_link or link_id,
"filter": filter_name,
# Prefer the value carried in the signal; fall back to the one we registered.
"tag": tag if tag and tag != "-" else registered_tag,
"ts": ts,
"len": int(length) if length and length.isdigit() else 0,
}
self._manager.emit_match(project_id, event)
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python
#
# Copyright (C) 2024 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/>.
import asyncio
import logging
from gns3server.compute.marker.marker_listener import MarkerListener
from gns3server.compute.notification_manager import NotificationManager
log = logging.getLogger(__name__)
class MarkerManager:
"""
Singleton owning the compute-side UDP sink for ubridge ``MARK`` signals and
the registry that maps each ``(node_id, filter_name)`` back to its
``(project_id, link_id, tag)``.
The registry is populated when a marker is created on a link (the compute
endpoint has project_id + node_id from its route path and link_id/name/tag
from the request body) and cleared when the marker is deleted or the project
closed. At signal time it is an O(1) lookup — no node-table scan, and the
signal payload is untouched.
One listener per compute process serves every ubridge on that host; source
ubridges are disambiguated by ``node=<id>`` (UUID, globally unique).
"""
def __init__(self):
self._listener = None
self._transport = None
self._host = None
self._port = None
# Flat lookup: (node_id, filter_name) -> {"project_id", "link_id", "tag"}
self._entries = {}
# Reverse index for O(1) per-project teardown: project_id -> set of keys
self._by_project = {}
@property
def host(self):
"""The host the UDP sink is reachable on (for ``marker sink``)."""
return self._host
@property
def port(self):
"""The UDP port the sink is bound on (for ``marker sink``)."""
return self._port
@property
def running(self):
return self._transport is not None
async def start(self, host="127.0.0.1", port=0):
"""
Bind the UDP sink. ``port=0`` lets the OS choose a free port, which is
then read back and exposed via :attr:`port` for ``marker sink`` commands.
"""
if self.running:
return
loop = asyncio.get_running_loop()
self._listener = MarkerListener(self)
try:
self._transport, _ = await loop.create_datagram_endpoint(
lambda: self._listener, local_addr=(host, port)
)
except OSError:
if port != 0:
log.warning(
"Marker listener: port %s unavailable, falling back to OS-assigned port", port
)
try:
self._transport, _ = await loop.create_datagram_endpoint(
lambda: self._listener, local_addr=(host, 0)
)
except OSError as e:
log.error(
"Marker listener startup failed: %s. Traffic insight signals are unavailable.", e
)
self._listener = None
return
else:
log.error(
"Marker listener startup failed on OS-assigned port. Traffic insight signals are unavailable."
)
self._listener = None
return
sock = self._transport.get_extra_info("socket")
self._host = host
self._port = sock.getsockname()[1] if sock else port
log.info("Marker signal sink listening on %s:%s", self._host, self._port)
async def stop(self):
"""Close the UDP sink and drop the whole registry."""
if self._transport:
self._transport.close()
self._transport = None
self._listener = None
self._entries.clear()
self._by_project.clear()
self._host = None
self._port = None
def register(self, project_id, node_id, filter_name, link_id, tag=None):
"""
Record that ``filter_name`` on ``node_id`` belongs to ``project_id`` /
``link_id``. Called from the compute marker-start endpoint.
Re-registering the same key updates the stored tag (e.g. on re-add).
"""
key = (node_id, filter_name)
self._entries[key] = {"project_id": project_id, "link_id": link_id, "tag": tag}
self._by_project.setdefault(project_id, set()).add(key)
def unregister(self, node_id, filter_name):
"""Forget a single marker. Returns True if something was removed."""
key = (node_id, filter_name)
entry = self._entries.pop(key, None)
if entry is None:
return False
project_entries = self._by_project.get(entry["project_id"])
if project_entries is not None:
project_entries.discard(key)
if not project_entries:
self._by_project.pop(entry["project_id"], None)
return True
def unregister_project(self, project_id):
"""Drop every marker belonging to ``project_id`` (project close)."""
keys = self._by_project.pop(project_id, None)
if not keys:
return
for key in keys:
self._entries.pop(key, None)
def lookup(self, node_id, filter_name):
"""
O(1) resolution of an incoming signal to its project/link/tag.
:returns: (project_id, link_id, tag) or (None, None, None) on miss.
"""
entry = self._entries.get((node_id, filter_name))
if entry is None:
return None, None, None
return entry["project_id"], entry["link_id"], entry["tag"]
def emit_match(self, project_id, event):
"""
Forward a parsed match as a project-scoped ``marker.match`` notification.
Flows compute -> controller dispatch -> project_emit -> web UI WS.
"""
NotificationManager.instance().emit("marker.match", event, project_id=project_id)
_instance = None
@staticmethod
def instance():
if MarkerManager._instance is None:
MarkerManager._instance = MarkerManager()
return MarkerManager._instance
@staticmethod
def reset():
MarkerManager._instance = None
+22
View File
@@ -30,6 +30,7 @@ class NIO:
self._capturing = False
self._suspended = False
self._filters = {}
self._markers = {}
self._pcap_output_file = ""
self._pcap_data_link_type = ""
@@ -118,3 +119,24 @@ class NIO:
assert isinstance(new_filters, dict)
self._filters = new_filters
@property
def markers(self):
"""
Returns the traffic-insight markers for this NIO.
:returns: markers (dictionary: name -> {bpf, tag, link_id})
"""
return self._markers
@markers.setter
def markers(self, new_markers):
"""
Set the traffic-insight markers for this NIO.
:param new_markers: markers (dictionary: name -> {bpf, tag, link_id})
"""
assert isinstance(new_markers, dict)
self._markers = new_markers
+2 -1
View File
@@ -80,5 +80,6 @@ class NIOUDP(NIO):
"rport": self._rport,
"rhost": self._rhost,
"suspend": self._suspended,
"filters": self._filters
"filters": self._filters,
"markers": self._markers
}
+16
View File
@@ -246,6 +246,22 @@ class Project:
raise ComputeError(f"Could not create the capture working directory: {e}")
return workdir
def markers_working_directory(self):
"""
Returns the working directory where uBridge writes per-link marker pcaps
(matched packets, kept for later replay).
:returns: path to the directory
"""
workdir = os.path.join(self._path, "project-files", "markers")
if not self._deleted:
try:
os.makedirs(workdir, exist_ok=True)
except OSError as e:
raise ComputeError(f"Could not create the markers working directory: {e}")
return workdir
def add_node(self, node):
"""
Adds a node to the project.