# # Copyright (C) 2015 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 . import sys import os import stat import shutil import asyncio import contextlib import json import struct import tempfile import psutil import platform import re import asyncssh from fastapi import WebSocketDisconnect from gns3server.utils.interfaces import interfaces from gns3server.compute.compute_error import ComputeError from ..compute.port_manager import PortManager from ..utils.asyncio import wait_run_in_executor, locking from ..utils.asyncio.ssh_server import AsyncioSSHServer from ..utils.asyncio.telnet_server import AsyncioTelnetServer from gns3server.compute.ubridge.hypervisor import Hypervisor from gns3server.compute.ubridge.ubridge_error import UbridgeError from .nios.nio_udp import NIOUDP from .error import NodeError import logging log = logging.getLogger(__name__) class BaseNode: """ Base node implementation. :param name: name of this node :param node_id: Node instance identifier :param project: Project instance :param manager: parent node manager :param console: console TCP port :param console_type: console type :param aux: auxiliary console TCP port :param aux_type: auxiliary console type :param linked_clone: The node base image is duplicate/overlay (Each node data are independent) :param wrap_console: The console is wrapped using a proxy transport server :param wrap_aux: The auxiliary console is wrapped using a proxy transport server """ def __init__( self, name, node_id, project, manager, console=None, console_type="telnet", aux=None, aux_type="none", linked_clone=True, wrap_console=False, wrap_aux=False, ): self._name = name self._usage = "" self._id = node_id self._linked_clone = linked_clone self._project = project self._manager = manager self._console = console self._aux = aux self._console_type = console_type self._aux_type = aux_type self._temporary_directory = None self._hw_virtualization = False self._ubridge_hypervisor = None self._closed = False self._node_status = "stopped" self._command_line = "" self._wrap_console = wrap_console self._wrap_aux = wrap_aux self._wrapper_telnet_servers = [] self._wrap_console_reader = None self._wrap_console_writer = None self._internal_console_port = None self._internal_aux_port = None self._custom_adapters = [] self._ubridge_require_privileged_access = False # marker filter name -> uBridge bridge_name (recorded at apply time so # _ubridge_set_marker_filter_state can toggle on/off without an NIO rebuild). self._marker_filter_bridges = {} # Parallel store of the installed marker spec (bpf/tag/direction/enabled/...) # keyed by (name, link_id) so _ubridge_apply_markers can reconcile: detect # deletions and field changes instead of being add-only. self._marker_specs = {} if self._console is not None: # use a previously allocated console port if console_type == "vnc": vnc_console_start_port_range, vnc_console_end_port_range = self._get_vnc_console_port_range() self._console = self._manager.port_manager.reserve_tcp_port( self._console, self._project, port_range_start=vnc_console_start_port_range, port_range_end=vnc_console_end_port_range, ) elif console_type == "none": self._console = None else: self._console = self._manager.port_manager.reserve_tcp_port(self._console, self._project) if self._aux is not None: # use a previously allocated auxiliary console port if aux_type == "vnc": # VNC is a special case and the range must be 5900-6000 self._aux = self._manager.port_manager.reserve_tcp_port( self._aux, self._project, port_range_start=5900, port_range_end=6000 ) elif aux_type == "none": self._aux = None else: self._aux = self._manager.port_manager.reserve_tcp_port(self._aux, self._project) if self._console is None: # allocate a new console if console_type == "vnc": vnc_console_start_port_range, vnc_console_end_port_range = self._get_vnc_console_port_range() self._console = self._manager.port_manager.get_free_tcp_port( self._project, port_range_start=vnc_console_start_port_range, port_range_end=vnc_console_end_port_range, ) elif console_type != "none": self._console = self._manager.port_manager.get_free_tcp_port(self._project) if self._aux is None: # allocate a new auxiliary console if aux_type == "vnc": # VNC is a special case and the range must be 5900-6000 self._aux = self._manager.port_manager.get_free_tcp_port( self._project, port_range_start=5900, port_range_end=6000 ) elif aux_type != "none": self._aux = self._manager.port_manager.get_free_tcp_port(self._project) if self._wrap_console: self._internal_console_port = self._manager.port_manager.get_free_tcp_port(self._project) if self._wrap_aux: self._internal_aux_port = self._manager.port_manager.get_free_tcp_port(self._project) log.debug( "{module}: {name} [{id}] initialized. Console port {console}".format( module=self.manager.module_name, name=self.name, id=self.id, console=self._console ) ) def __del__(self): if hasattr(self, "_temporary_directory") and self._temporary_directory is not None: if os.path.exists(self._temporary_directory): shutil.rmtree(self._temporary_directory, ignore_errors=True) @property def linked_clone(self): return self._linked_clone @linked_clone.setter def linked_clone(self, val): self._linked_clone = val @property def custom_adapters(self): return self._custom_adapters @custom_adapters.setter def custom_adapters(self, val): self._custom_adapters = val @property def status(self): """ Returns current node status """ return self._node_status @status.setter def status(self, status): self._node_status = status self.updated() def updated(self): """ Sends an updated event """ self.project.emit("node.updated", self) @property def command_line(self): """ Returns command used to start the node """ return self._command_line @command_line.setter def command_line(self, command_line): self._command_line = command_line @property def project(self): """ Returns the node current project. :returns: Project instance. """ return self._project @property def name(self): """ Returns the name for this node. :returns: name """ return self._name @name.setter def name(self, new_name): """ Sets the name of this node. :param new_name: name """ log.info( "{module}: {name} [{id}] renamed to {new_name}".format( module=self.manager.module_name, name=self.name, id=self.id, new_name=new_name ) ) self._name = new_name @property def usage(self): """ Returns the usage for this node. :returns: usage """ return self._usage @usage.setter def usage(self, new_usage): """ Sets the usage of this node. :param new_usage: usage """ self._usage = new_usage @property def id(self): """ Returns the ID for this node. :returns: Node identifier (string) """ return self._id @property def manager(self): """ Returns the manager for this node. :returns: instance of manager """ return self._manager @property def working_dir(self): """ Return the node working directory """ return self._project.node_working_directory(self) @property def working_path(self): """ Return the node working path. Doesn't create structure of directories when not present. """ return self._project.node_working_path(self) @property def temporary_directory(self): if self._temporary_directory is None: try: self._temporary_directory = tempfile.mkdtemp() except OSError as e: raise NodeError(f"Can't create temporary directory: {e}") return self._temporary_directory def create(self): """ Creates the node. """ log.debug("{module}: {name} [{id}] created".format(module=self.manager.module_name, name=self.name, id=self.id)) async def delete(self): """ Delete the node (including all its files). """ def set_rw(operation, name, exc): os.chmod(name, stat.S_IWRITE) directory = self.project.node_working_directory(self) if os.path.exists(directory): try: await wait_run_in_executor(shutil.rmtree, directory, onerror=set_rw) except OSError as e: raise ComputeError(f"Could not delete the node working directory: {e}") def start(self): """ Starts the node process. """ raise NotImplementedError async def stop(self): """ Stop the node process. """ try: await self.stop_wrap_console() finally: self.status = "stopped" def suspend(self): """ Suspends the node process. """ raise NotImplementedError async def close(self): """ Close the node process. """ if self._closed: return False log.debug( "{module}: '{name}' [{id}]: is closing".format(module=self.manager.module_name, name=self.name, id=self.id) ) if self._console: self._manager.port_manager.release_tcp_port(self._console, self._project) self._console = None if self._wrap_console: self._manager.port_manager.release_tcp_port(self._internal_console_port, self._project) self._internal_console_port = None if self._aux: self._manager.port_manager.release_tcp_port(self._aux, self._project) self._aux = None if self._wrap_aux: self._manager.port_manager.release_tcp_port(self._internal_aux_port, self._project) self._internal_aux_port = None self._closed = True return True def _get_vnc_console_port_range(self): """ Returns the VNC console port range. """ vnc_console_start_port_range = self._manager.config.settings.Server.vnc_console_start_port_range vnc_console_end_port_range = self._manager.config.settings.Server.vnc_console_end_port_range if not 5900 <= vnc_console_start_port_range <= 65535: raise NodeError("The VNC console start port range must be between 5900 and 65535") if not 5900 <= vnc_console_end_port_range <= 65535: raise NodeError("The VNC console start port range must be between 5900 and 65535") if vnc_console_start_port_range >= vnc_console_end_port_range: raise NodeError( f"The VNC console start port range value ({vnc_console_start_port_range}) " f"cannot be above or equal to the end value ({vnc_console_end_port_range})" ) return vnc_console_start_port_range, vnc_console_end_port_range async def _wrap_console_proxy(self, internal_port, external_port, console_type): """ Start a console proxy allowing multiple external clients to be connected at the same time. """ remaining_trial = 60 while True: try: (self._wrap_console_reader, self._wrap_console_writer) = await asyncio.open_connection( host="127.0.0.1", port=internal_port ) break except (OSError, ConnectionRefusedError) as e: if remaining_trial <= 0: raise e await asyncio.sleep(0.1) remaining_trial -= 1 if console_type == "telnet": await AsyncioTelnetServer.write_client_intro(self._wrap_console_writer, echo=True) server = AsyncioTelnetServer( reader=self._wrap_console_reader, writer=self._wrap_console_writer, binary=True, echo=True ) elif console_type == "ssh": server = AsyncioSSHServer(reader=self._wrap_console_reader, writer=self._wrap_console_writer) else: raise NodeError(f"Console wrapper does not support type {console_type}") # warning: this will raise OSError exception if there is a problem... proxy_server = await server.start(self._manager.port_manager.console_host, external_port) self._wrapper_telnet_servers.append(proxy_server) async def start_wrap_console(self): """ Start console proxy servers for the console and auxiliary console allowing multiple clients to be connected at the same time """ if self._wrap_console and self._console_type in ("telnet", "ssh"): await self._wrap_console_proxy(self._internal_console_port, self.console, self._console_type) log.info( f"New {self._console_type.upper()} proxy server for console started " f"(internal port = {self._internal_console_port}, external port = {self.console})" ) if self._wrap_aux and self._aux_type in ("telnet", "ssh"): await self._wrap_console_proxy(self._internal_aux_port, self.aux, self._aux_type) log.info( f"New {self._aux_type.upper()} proxy server for auxiliary console started " f"(internal port = {self._internal_aux_port}, external port = {self.aux})" ) async def stop_wrap_console(self): """ Stops the console proxy servers. """ if self._wrap_console_writer: self._wrap_console_writer.close() try: await self._wrap_console_writer.wait_closed() except (ConnectionResetError, BrokenPipeError, OSError): # Connection already closed, ignore error pass for telnet_proxy_server in self._wrapper_telnet_servers: telnet_proxy_server.close() try: await telnet_proxy_server.wait_closed() except (ConnectionResetError, BrokenPipeError, OSError): # Connection already closed, ignore error pass self._wrapper_telnet_servers = [] async def reset_wrap_console(self): """ Reset the wrap console (restarts the console proxy) """ await self.stop_wrap_console() await self.start_wrap_console() async def start_websocket_console(self, websocket): """ Connect to console using Websocket. :param websocket: FastAPI WebSocket object """ log.info( f"New client {websocket.client.host}:{websocket.client.port} has connected to compute" f" console WebSocket" ) if self.status != "started": await websocket.close(code=1000) log.warning(f"Cannot open console WebSocket: node {self.name} is not started") return if self._console_type not in ("telnet", "ssh", "docker_exec"): await websocket.close(code=1000) log.warning( f"Cannot open console WebSocket: node {self.name} console type '{self._console_type}' " f"is not supported" ) return telnet_reader = None telnet_writer = None ssh_connection = None ssh_process = None try: host = self._manager.port_manager.console_host if self._console_type == "ssh": # For SSH consoles (wrapped or not), connect to the external SSH port via an # SSH client. The AsyncioSSHServer on that port handles multi-client broadcasting # to/from the node's process streams. Connecting directly to _internal_console_port # would conflict with the exclusive connection already held by the SSH proxy. port = self.console ssh_connection = await asyncssh.connect( host, port=port, username="gns3", known_hosts=None, encoding=None, ) ssh_process = await ssh_connection.create_process(encoding=None, term_type="xterm") telnet_reader = ssh_process.stdout telnet_writer = ssh_process.stdin else: port = self.console (telnet_reader, telnet_writer) = await asyncio.open_connection(host, port) log.info(f"Connected to local console stream {host}:{port} (console type={self._console_type})") except (ConnectionError, OSError, asyncssh.Error) as e: await websocket.close(code=1000) log.warning(f"Cannot connect to node {self.name} console server: {e}") return def _parse_terminal_size_message(data: bytes): """ Binary control frames sent by WebSocket console clients to propagate their terminal geometry: {"cols": int, "rows": int}. Terminal data travels as text frames (xterm.js AttachAddon), so binary frames are an unambiguous side channel. Returns (cols, rows) or None. """ try: message = json.loads(data.decode("utf-8")) except (UnicodeDecodeError, ValueError): return None if not isinstance(message, dict): return None cols, rows = message.get("cols"), message.get("rows") if ( isinstance(cols, int) and not isinstance(cols, bool) and isinstance(rows, int) and not isinstance(rows, bool) and 2 <= cols <= 5000 and 2 <= rows <= 100000 ): return cols, rows return None async def resize_console(cols: int, rows: int) -> None: """ Propagate a client terminal resize to the node console stream: SSH channels use a pty request update, telnet-based consoles (including docker_exec) speak a NAWS subnegotiation to the console telnet server, which resizes the underlying stream (e.g. the docker exec pty). """ if self._console_type == "ssh": with contextlib.suppress(AttributeError): ssh_process.change_terminal_size(cols, rows) else: telnet_writer.write( bytes([255, 251, 31]) # IAC WILL NAWS + bytes([255, 250, 31]) # IAC SB NAWS + struct.pack("!HH", cols, rows).replace(b"\xff", b"\xff\xff") + bytes([255, 240]) # IAC SE ) await telnet_writer.drain() async def ws_forward(telnet_writer): try: while True: msg = await websocket.receive() if msg["type"] == "websocket.disconnect": break if "text" in msg and msg["text"]: data = msg["text"].encode() elif "bytes" in msg and msg["bytes"]: size = _parse_terminal_size_message(msg["bytes"]) if size is not None: log.debug( f"Console WebSocket client {websocket.client.host}:{websocket.client.port}" f" resized terminal to {size[0]}x{size[1]}" ) await resize_console(*size) continue data = msg["bytes"] else: continue telnet_writer.write(data) await telnet_writer.drain() except WebSocketDisconnect: log.info( f"Client {websocket.client.host}:{websocket.client.port} has disconnected from compute" f" console WebSocket" ) async def telnet_forward(telnet_reader): while not telnet_reader.at_eof(): data = await telnet_reader.read(1024) if data: await websocket.send_bytes(data) # keep forwarding websocket data in both direction if sys.version_info >= (3, 11, 0): # Starting with Python 3.11, passing coroutine objects to wait() directly is forbidden. aws = [asyncio.create_task(ws_forward(telnet_writer)), asyncio.create_task(telnet_forward(telnet_reader))] else: aws = [ws_forward(telnet_writer), telnet_forward(telnet_reader)] done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED) for task in done: if task.exception(): log.warning( f"Exception while forwarding WebSocket data to " f"{self._console_type.upper()} server: {task.exception()}" ) for task in pending: task.cancel() if ssh_connection: if ssh_process: ssh_process.close() ssh_connection.close() await ssh_connection.wait_closed() if telnet_writer and hasattr(telnet_writer, "close"): telnet_writer.close() if hasattr(telnet_writer, "wait_closed"): await telnet_writer.wait_closed() async def start_vnc_websocket_console(self, websocket): """ Connect to VNC console using WebSocket. :param websocket: FastAPI WebSocket object """ log.info( f"New client {websocket.client.host}:{websocket.client.port} has connected to compute " f"VNC console WebSocket" ) if self.status != "started": await websocket.close(code=1000) log.warning(f"Cannot open VNC WebSocket: node {self.name} is not started") return if self._console_type != "vnc": await websocket.close(code=1000) log.warning( f"Cannot open VNC WebSocket: node {self.name} console type '{self._console_type}' is not vnc" ) return try: vnc_reader, vnc_writer = await asyncio.open_connection( self._manager.port_manager.console_host, self.console # VNC port ) log.info(f"Connected to VNC server {self._manager.port_manager.console_host}:{self.console}") except ConnectionError as e: await websocket.close(code=1000) log.warning(f"Cannot connect to node {self.name} VNC server: {e}") return async def ws_forward(vnc_writer): # Browser → VNC: Forward binary WebSocket data to VNC server try: while True: data = await websocket.receive_bytes() if data: vnc_writer.write(data) await vnc_writer.drain() except WebSocketDisconnect: log.info( f"Client {websocket.client.host}:{websocket.client.port} has disconnected from compute " f"VNC console WebSocket" ) async def vnc_forward(vnc_reader): # VNC → Browser: Forward VNC frames to WebSocket try: while not vnc_reader.at_eof(): data = await vnc_reader.read(65536) # Larger buffer for VNC frames if data: await websocket.send_bytes(data) except Exception as e: log.warning(f"Exception while forwarding VNC data to WebSocket: {e}") # Keep forwarding WebSocket data in both directions if sys.version_info >= (3, 11, 0): # Starting with Python 3.11, passing coroutine objects to wait() directly is forbidden. aws = [asyncio.create_task(ws_forward(vnc_writer)), asyncio.create_task(vnc_forward(vnc_reader))] else: aws = [ws_forward(vnc_writer), vnc_forward(vnc_reader)] done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED) for task in done: if task.exception(): log.warning(f"Exception while forwarding WebSocket data to VNC server: {task.exception()}") for task in pending: task.cancel() vnc_writer.close() await vnc_writer.wait_closed() @property def aux(self): """ Returns the aux console port of this node. :returns: aux console port """ return self._aux @aux.setter def aux(self, aux): """ Changes the aux port :params aux: Console port (integer) or None to free the port """ if aux == self._aux or self._aux_type == "none": return if self._aux_type == "vnc" and aux is not None and aux < 5900: raise NodeError(f"VNC auxiliary console require a port superior or equal to 5900, current port is {aux}") if self._aux: self._manager.port_manager.release_tcp_port(self._aux, self._project) self._aux = None if aux is not None: if self.aux_type == "vnc": self._aux = self._manager.port_manager.reserve_tcp_port( aux, self._project, port_range_start=5900, port_range_end=6000 ) else: self._aux = self._manager.port_manager.reserve_tcp_port(aux, self._project) log.info( "{module}: '{name}' [{id}]: auxiliary console port set to {port}".format( module=self.manager.module_name, name=self.name, id=self.id, port=aux ) ) @property def console(self): """ Returns the console port of this node. :returns: console port """ return self._console @console.setter def console(self, console): """ Changes the console port :params console: Console port (integer) or None to free the port """ if console == self._console or self._console_type == "none": return if self._console_type == "vnc" and console is not None and console < 5900: raise NodeError(f"VNC console require a port superior or equal to 5900, current port is {console}") if self._console: self._manager.port_manager.release_tcp_port(self._console, self._project) self._console = None if console is not None: if self.console_type == "vnc": vnc_console_start_port_range, vnc_console_end_port_range = self._get_vnc_console_port_range() self._console = self._manager.port_manager.reserve_tcp_port( console, self._project, port_range_start=vnc_console_start_port_range, port_range_end=vnc_console_end_port_range, ) else: self._console = self._manager.port_manager.reserve_tcp_port(console, self._project) log.info( "{module}: '{name}' [{id}]: console port set to {port}".format( module=self.manager.module_name, name=self.name, id=self.id, port=console ) ) @property def console_type(self): """ Returns the console type for this node. :returns: console type (string) """ return self._console_type @console_type.setter def console_type(self, console_type): """ Sets the console type for this node. :param console_type: console type (string) """ if console_type != self._console_type: # get a new port if the console type change if self._console: self._manager.port_manager.release_tcp_port(self._console, self._project) if console_type == "none": # no need to allocate a port when the console type is none self._console = None elif console_type == "vnc": vnc_console_start_port_range, vnc_console_end_port_range = self._get_vnc_console_port_range() self._console = self._manager.port_manager.get_free_tcp_port( self._project, vnc_console_start_port_range, vnc_console_end_port_range ) else: self._console = self._manager.port_manager.get_free_tcp_port(self._project) self._console_type = console_type log.info( "{module}: '{name}' [{id}]: console type set to {console_type} (console port is {console})".format( module=self.manager.module_name, name=self.name, id=self.id, console_type=console_type, console=self.console, ) ) @property def aux_type(self): """ Returns the auxiliary console type for this node. :returns: aux type (string) """ return self._aux_type @aux_type.setter def aux_type(self, aux_type): """ Sets the auxiliary console type for this node. :param aux_type: console type (string) """ if aux_type != self._aux_type: # get a new port if the aux type change if self._aux: self._manager.port_manager.release_tcp_port(self._aux, self._project) if aux_type == "none": # no need to allocate a port when the auxiliary console type is none self._aux = None elif aux_type == "vnc": # VNC is a special case and the range must be 5900-6000 self._aux = self._manager.port_manager.get_free_tcp_port(self._project, 5900, 6000) else: self._aux = self._manager.port_manager.get_free_tcp_port(self._project) self._aux_type = aux_type log.info( "{module}: '{name}' [{id}]: console type set to {aux_type} (auxiliary console port is {aux})".format( module=self.manager.module_name, name=self.name, id=self.id, aux_type=aux_type, aux=self.aux ) ) @property def ubridge(self): """ Returns the uBridge hypervisor. :returns: instance of uBridge """ if self._ubridge_hypervisor and not self._ubridge_hypervisor.is_running(): self._ubridge_hypervisor = None return self._ubridge_hypervisor @ubridge.setter def ubridge(self, ubride_hypervisor): """ Set an uBridge hypervisor. :param ubride_hypervisor: uBridge hypervisor """ self._ubridge_hypervisor = ubride_hypervisor @property def ubridge_path(self): """ Returns the uBridge executable path. :returns: path to uBridge """ path = shutil.which(self._manager.config.settings.Server.ubridge_path) return path @locking async def _ubridge_send(self, command): """ Sends a command to uBridge hypervisor. :param command: command to send """ if not self._ubridge_hypervisor or not self._ubridge_hypervisor.is_running(): await self._start_ubridge(self._ubridge_require_privileged_access) if not self._ubridge_hypervisor or not self._ubridge_hypervisor.is_running(): raise NodeError(f"Cannot send command '{command}': uBridge is not running") try: await self._ubridge_hypervisor.send(command) except UbridgeError as e: raise UbridgeError( f"Error while sending command '{command}': {e}: {self._ubridge_hypervisor.read_stdout()}" ) @locking async def _start_ubridge(self, require_privileged_access=False): """ Starts uBridge (handles connections to and from this node). """ # Prevent us to start multiple ubridge if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running(): return if self.ubridge_path is None: raise NodeError( "uBridge is not available, path doesn't exist, or you just installed GNS3 and need to restart your user session to refresh user permissions." ) if require_privileged_access and not self._manager.has_privileged_access(self.ubridge_path): raise NodeError("uBridge requires root access or the capability to interact with network adapters") server_host = self._manager.config.settings.Server.host transport = self._manager.config.settings.Server.ubridge_control_transport if not self.ubridge: self._ubridge_hypervisor = Hypervisor( self._project, self.ubridge_path, self.working_dir, transport, server_host, self.id ) log.debug(f"Starting new uBridge hypervisor at {self._ubridge_hypervisor.endpoint}") await self._ubridge_hypervisor.start() if self._ubridge_hypervisor: log.info( f"Hypervisor at {self._ubridge_hypervisor.endpoint} 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. """ if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running(): log.debug(f"Stopping uBridge hypervisor at {self._ubridge_hypervisor.endpoint}") await self._ubridge_hypervisor.stop() self._ubridge_hypervisor = None # uBridge is gone, so every marker filter (and its in-bridge state) is # gone too — clear the map so the next apply re-installs them all rather # than skipping them as "already installed". self._marker_filter_bridges.clear() self._marker_specs.clear() async def add_ubridge_udp_connection(self, bridge_name, source_nio, destination_nio): """ Creates an UDP connection in uBridge. :param bridge_name: bridge name in uBridge :param source_nio: source NIO instance :param destination_nio: destination NIO instance """ await self._ubridge_send(f"bridge create {bridge_name}") if not isinstance(destination_nio, NIOUDP): raise NodeError("Destination NIO is not UDP") await self._ubridge_send( "bridge add_nio_udp {name} {lport} {rhost} {rport}".format( name=bridge_name, lport=source_nio.lport, rhost=source_nio.rhost, rport=source_nio.rport ) ) await self._ubridge_send( "bridge add_nio_udp {name} {lport} {rhost} {rport}".format( name=bridge_name, lport=destination_nio.lport, rhost=destination_nio.rhost, rport=destination_nio.rport ) ) if destination_nio.capturing: await self._ubridge_send( 'bridge start_capture {name} "{pcap_file}"'.format( name=bridge_name, pcap_file=destination_nio.pcap_output_file ) ) 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): """ :params name: Delete the bridge with this name """ if self.ubridge: await self._ubridge_send(f"bridge delete {name}") async def _ubridge_apply_filters(self, bridge_name, filters): """ Apply packet filters :param bridge_name: bridge name in uBridge :param filters: Array of filter dictionary """ await self._ubridge_send("bridge reset_packet_filters " + bridge_name) for packet_filter in self._build_filter_list(filters): cmd = f"bridge add_packet_filter {bridge_name} {packet_filter}" try: await self._ubridge_send(cmd) except UbridgeError as e: match = re.search(r"Cannot compile filter '(.*)': syntax error", str(e)) if match: message = f"Warning: ignoring BPF packet filter '{self.name}' due to syntax error: {match.group(1)}" log.warning(message) self.project.emit("log.warning", {"message": message}) else: raise def _build_filter_list(self, filters): """ :returns: Iterator building a list of filter """ i = 0 for (filter_type, values) in filters.items(): if isinstance(values[0], str): for line in values[0].split("\n"): line = line.strip() yield "{filter_name} {filter_type} {filter_value}".format( filter_name="filter" + str(i), filter_type=filter_type, filter_value='"{}" {}'.format(line, " ".join([str(v) for v in values[1:]])), ).strip() i += 1 else: yield "{filter_name} {filter_type} {filter_value}".format( filter_name="filter" + str(i), filter_type=filter_type, filter_value=" ".join([str(v) for v in values]), ) i += 1 @staticmethod def _marker_linktype(data_link_type): """ Normalize a GNS3 pcap data-link type (e.g. ``DLT_C_HDLC``) to the bare uBridge ``linktype`` token (``C_HDLC``) by stripping the ``DLT_`` prefix. Returns ``None`` for Ethernet (``DLT_EN10MB`` / unset) so the ``linktype`` keyword is omitted and uBridge defaults to EN10MB. Values come straight from ``SerialPort.data_link_types`` (the single source of truth); uBridge resolves them with ``pcap_datalink_name_to_val``, which is case-sensitive and expects the canonical uppercase form. """ if not data_link_type: return None dlt = data_link_type.upper() if dlt.startswith("DLT_"): dlt = dlt[4:] return None if dlt == "EN10MB" else dlt async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None, direction=None, data_link_type=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 [tag ] [pcap ] — 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_.-]*$") # Defense-in-depth vs hand-edited topology: the user-facing name is capped # at 32 by the schema; inherited copies carry a ``global-`` prefix (≤ 39), # so allow up to 48 here. if not _MARKER_NAME_RE.match(name) or len(name) > 48: 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}" if direction is not None: cmd += f" dir {direction}" linktype = self._marker_linktype(data_link_type) if linktype is not None: cmd += f" linktype {linktype}" 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 delete_marker_capture(self, name, link_id, nio=None): """ Remove a marker from uBridge (fine-grained ``delete_packet_filter`` — NOT reset_packet_filters, so sibling markers' pcaps aren't closed/reopened) and delete its capture pcap. Called by the controller when a marker is removed; safe with the node stopped (filter removal is skipped, the file is still unlinked). IOU overrides ``_ubridge_delete_marker_filter`` for its ``iol_bridge`` command shape. ``nio`` is the port NIO whose cached ``nio.markers`` carries this marker spec; it is dropped here so a later node start / NIO reapply (``_ubridge_apply_markers``) does not reinstall the marker. Without this, deleting a marker while the node is stopped left the spec in ``nio.markers``, and starting the node recreated an empty pcap. """ if nio is not None and getattr(nio, "markers", None): nio.markers.pop(name, None) bridge_name = self._marker_filter_bridges.pop((name, link_id), None) self._marker_specs.pop((name, link_id), None) if bridge_name is not None: await self._ubridge_delete_marker_filter(bridge_name, name) try: markers_dir = self.project.markers_working_directory() pcap_path = os.path.join(markers_dir, f"{self._id}_{link_id}_{name}.pcap") os.remove(pcap_path) except FileNotFoundError: pass except OSError as e: log.warning("Could not remove marker pcap for '%s' on link %s: %s", name, link_id, e) async def _ubridge_delete_marker_filter(self, bridge_name, name): """ Remove a single marker filter from uBridge with ``delete_packet_filter`` (not a bridge-wide reset) so other markers keep their pcaps open. A no-op when uBridge isn't running — the pcap cleanup in the caller still proceeds. """ if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running()): return try: await self._ubridge_send(f"bridge delete_packet_filter {bridge_name} {name}") except UbridgeError as e: log.warning("Could not remove marker filter '%s' from %s: %s", name, bridge_name, e) async def rebuild_marker_filter(self, name, link_id, bpf, tag=None, direction=None, enabled=True): """ Re-install a single marker filter with new params (delete + add), without a bridge-wide reset — so sibling markers keep their pcaps open. uBridge reopens the marker's own pcap on re-add (a new capture session for the new BPF), which is expected. No-op if the marker isn't installed (node stopped) — the next NIO reapply picks up the updated ``_markers``. IOU needs no override: this calls ``_ubridge_delete_marker_filter`` / ``_ubridge_add_marker_filter`` / ``_ubridge_set_marker_filter_state``, all of which IOU already overrides for ``iol_bridge``. """ bridge_name = self._marker_filter_bridges.get((name, link_id)) if bridge_name is None: return await self._ubridge_delete_marker_filter(bridge_name, name) pcap_path = os.path.join(self.project.markers_working_directory(), f"{self._id}_{link_id}_{name}.pcap") await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag, link_id, direction=direction) if not enabled: await self._ubridge_set_marker_filter_state(name, enabled=False) async def _ubridge_apply_markers(self, bridge_name, nio): """ Reconcile the traffic-insight markers carried by *nio* onto bridge *bridge_name* with what is already installed there. uBridge's ``reset_packet_filters`` preserves mark filters (contract), so a plain re-add would duplicate them; instead this diffs the desired ``nio.markers`` against the installed ``_marker_specs``: * installed but no longer desired → delete filter + unlink pcap * desired with changed bpf/tag/direction/data_link_type → rebuild (delete + add; the marker's own pcap reopens for the new BPF) * desired with only ``enabled`` changed → instant on/off toggle (sibling and own pcap stay open) * desired and unchanged → skip * desired and new → add Called from ``add_ubridge_udp_connection`` (fresh bridge, empty maps → installs all) and ``update_ubridge_udp_connection`` / the batch NIO update path (incremental reconcile). """ from gns3server.compute.marker.marker_manager import MarkerManager markers = nio.markers if hasattr(nio, 'markers') else {} manager = MarkerManager.instance() markers_dir = self.project.markers_working_directory() desired = {(name, spec.get("link_id", "")): spec for name, spec in markers.items()} # 1. Remove installed markers that are no longer desired (marker/def delete). # Scope to THIS bridge: the map is node-wide and also holds markers # installed on this node's other links/NIOs. Without this guard, # reconciling one NIO would delete every other link's markers + pcaps # (desired only carries the current NIO's markers) — a regression. for key in list(self._marker_filter_bridges): if self._marker_filter_bridges[key] != bridge_name: continue if key not in desired: mname, link_id = key installed_bridge = self._marker_filter_bridges.pop(key) self._marker_specs.pop(key, None) await self._ubridge_delete_marker_filter(installed_bridge, mname) try: os.remove(os.path.join(markers_dir, f"{self._id}_{link_id}_{mname}.pcap")) except FileNotFoundError: pass except OSError as e: log.warning("Could not remove marker pcap for '%s' on link %s: %s", mname, link_id, e) manager.unregister(self._id, mname) # 2. Add newly-desired markers; rebuild ones whose filter fields changed. rebuild_fields = ("bpf", "tag", "direction", "data_link_type") for (name, link_id), spec in desired.items(): bpf = spec.get("bpf", "") tag = spec.get("tag") enabled = spec.get("enabled", True) if (name, link_id) in self._marker_filter_bridges: installed_spec = self._marker_specs.get((name, link_id)) if installed_spec is None: # Installed but no recorded spec (legacy / pre-reconcile state): # cannot diff, skip to avoid a duplicate add. continue if any(installed_spec.get(f) != spec.get(f) for f in rebuild_fields): # A filter field changed → rebuild (delete + re-add). installed_bridge = self._marker_filter_bridges.get((name, link_id)) await self._ubridge_delete_marker_filter(installed_bridge, name) elif installed_spec.get("enabled", True) != enabled: # Only the on/off state changed → instant toggle, pcap preserved. await self._ubridge_set_marker_filter_state(name, enabled) self._marker_specs[(name, link_id)] = spec continue else: continue # unchanged 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, direction=spec.get("direction"), data_link_type=spec.get("data_link_type")) 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 # A disabled marker is installed but turned off (a paused tap), not # dropped — so the UI can flip it back on instantly with # enable_packet_filter, no NIO rebuild (ubridge contract §3.2). if not enabled: try: await self._ubridge_send(f"bridge enable_packet_filter {bridge_name} {name} off") except UbridgeError as e: # Old ubridge without enable_packet_filter: leave it installed # (on) rather than fail the whole link/marker apply. log.warning(f"Could not turn marker '{name}' off on {bridge_name}: {e}") manager.register(str(self.project.id), self._id, name, link_id, tag) # Remember which bridge hosts this filter so an instant on/off toggle # (no NIO rebuild) can resolve it by name alone, and keep the spec so # the next reconcile can detect changes. self._marker_filter_bridges[name, link_id] = bridge_name self._marker_specs[name, link_id] = spec async def _ubridge_set_marker_filter_state(self, name, enabled): """ Toggle an installed marker filter on/off with a single uBridge command (``bridge enable_packet_filter … on|off``) — no NIO reset/reapply, so the pcap identity and emitted counter are preserved (ubridge contract §3.2). The bridge is resolved from the (name, link_id)→bridge map populated at apply time; entries are iterated so a node that hosts the same marker name on several links (e.g. IOU with one IOL-BRIDGE per node) toggles every copy. IOU overrides this for its ``iol_bridge`` command shape. :param name: marker filter name :param enabled: True = on (signal+pcap), False = off (paused tap) """ state = "on" if enabled else "off" for (n, lid), bridge_name in list(self._marker_filter_bridges.items()): if n == name: await self._ubridge_send(f"bridge enable_packet_filter {bridge_name} {name} {state}") async def _ubridge_marker_pause(self): """ Pause all marker signal+pcap emission on this node's uBridge (``marker pause``). Keeps the sink open so ``resume`` is instant. Safe on old ubridge builds (the error is downgraded to a warning). Called by the project-level pause fan-out. """ if self._ubridge_hypervisor: try: await self._ubridge_hypervisor.send("marker pause") except UbridgeError as e: log.warning(f"Could not pause markers on node {self._id}: {e}") async def _ubridge_marker_resume(self): """Resume marker signal+pcap emission (``marker resume``).""" if self._ubridge_hypervisor: try: await self._ubridge_hypervisor.send("marker resume") except UbridgeError as e: log.warning(f"Could not resume markers on node {self._id}: {e}") async def _add_ubridge_ethernet_connection(self, bridge_name, ethernet_interface, block_host_traffic=False): """ Creates a connection with an Ethernet interface in uBridge. :param bridge_name: bridge name in uBridge :param ethernet_interface: Ethernet interface name :param block_host_traffic: block network traffic originating from the host OS (Windows only) """ if sys.platform.startswith("linux") and block_host_traffic is False: # on Linux we use RAW sockets by default excepting if host traffic must be blocked await self._ubridge_send( 'bridge add_nio_linux_raw {name} "{interface}"'.format(name=bridge_name, interface=ethernet_interface) ) else: # on other platforms we just rely on the pcap library await self._ubridge_send( 'bridge add_nio_ethernet {name} "{interface}"'.format(name=bridge_name, interface=ethernet_interface) ) source_mac = None for interface in interfaces(): if interface["name"] == ethernet_interface: source_mac = interface["mac_address"] if source_mac: await self._ubridge_send( 'bridge set_pcap_filter {name} "not ether src {mac}"'.format(name=bridge_name, mac=source_mac) ) log.info(f"PCAP filter applied on '{ethernet_interface}' for source MAC {source_mac}") def _create_local_udp_tunnel(self): """ Creates a local UDP tunnel (pair of 2 NIOs, one for each direction) :returns: source NIO and destination NIO. """ m = PortManager.instance() lport = m.get_free_udp_port(self.project) rport = m.get_free_udp_port(self.project) source_nio_settings = {"lport": lport, "rhost": "127.0.0.1", "rport": rport, "type": "nio_udp"} destination_nio_settings = {"lport": rport, "rhost": "127.0.0.1", "rport": lport, "type": "nio_udp"} source_nio = self.manager.create_nio(source_nio_settings) destination_nio = self.manager.create_nio(destination_nio_settings) log.info( "{module}: '{name}' [{id}]:local UDP tunnel created between port {port1} and {port2}".format( module=self.manager.module_name, name=self.name, id=self.id, port1=lport, port2=rport ) ) return source_nio, destination_nio @property def hw_virtualization(self): """ Returns either the node is using hardware virtualization or not. :return: boolean """ return self._hw_virtualization def check_available_ram(self, requested_ram): """ Sends a warning notification if there is not enough RAM on the system to allocate requested RAM. :param requested_ram: requested amount of RAM in MB """ available_ram = int(psutil.virtual_memory().available / (1024 * 1024)) percentage_left = 100 - psutil.virtual_memory().percent if requested_ram > available_ram: message = '"{}" requires {}MB of RAM to run but there is only {}MB - {}% of RAM left on "{}"'.format( self.name, requested_ram, available_ram, percentage_left, platform.node() ) self.project.emit("log.warning", {"message": message}) def _get_custom_adapter_settings(self, adapter_number): if self.custom_adapters: for custom_adapter in self.custom_adapters: if custom_adapter["adapter_number"] == adapter_number: return custom_adapter return {}