From 3d3e20f69e8f967d884fe4b6e925a14cdb08883c Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 31 Jul 2026 22:27:16 +0800 Subject: [PATCH] Add configurable uBridge control channel transport (tcp/unix) Add ubridge_control_transport to [Server] config (default "tcp", fully backward compatible). Selecting "unix" switches the uBridge hypervisor control channel from the unauthenticated TCP listener (-H) to an AF_UNIX socket (-U) authenticated in-kernel via SO_PEERCRED. - UBridgeHypervisor: supports both socket_path (AF_UNIX) and host/port (TCP); a new `endpoint` property unifies log/error strings for both - Hypervisor: unix mode allocates a short socket path under a 0700 private runtime dir and unlinks it on stop; tcp mode restores the original getaddrinfo ephemeral-port allocation - base_node: reads the transport from config and passes host through - schemas/config.py + config_samples/gns3_server.conf: new option Default deployments are unchanged. "unix" requires a ubridge build that understands -U. --- gns3server/compute/base_node.py | 11 +- gns3server/compute/ubridge/hypervisor.py | 54 +++++++-- .../compute/ubridge/ubridge_hypervisor.py | 103 ++++++++---------- gns3server/config_samples/gns3_server.conf | 5 + gns3server/schemas/config.py | 14 +++ 5 files changed, 111 insertions(+), 76 deletions(-) diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index df92de0aa..a97b17de3 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -926,13 +926,16 @@ class BaseNode: 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, server_host) - log.info(f"Starting new uBridge hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port}") + self._ubridge_hypervisor = Hypervisor( + self._project, self.ubridge_path, self.working_dir, transport, server_host + ) + log.info(f"Starting new uBridge hypervisor at {self._ubridge_hypervisor.endpoint}") await self._ubridge_hypervisor.start() if self._ubridge_hypervisor: log.info( - f"Hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port} has successfully started" + 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 @@ -981,7 +984,7 @@ class BaseNode: """ if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running(): - log.info(f"Stopping uBridge hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port}") + log.info(f"Stopping uBridge hypervisor at {self._ubridge_hypervisor.endpoint}") await self._ubridge_hypervisor.stop() self._ubridge_hypervisor = None diff --git a/gns3server/compute/ubridge/hypervisor.py b/gns3server/compute/ubridge/hypervisor.py index a702adb34..bc38b3a47 100644 --- a/gns3server/compute/ubridge/hypervisor.py +++ b/gns3server/compute/ubridge/hypervisor.py @@ -20,9 +20,10 @@ Represents a uBridge hypervisor and starts/stops the associated uBridge process. import sys import os +import socket import subprocess import asyncio -import socket +import tempfile import re from gns3server.utils import parse_version @@ -44,17 +45,36 @@ class Hypervisor(UBridgeHypervisor): :param project: Project instance :param path: path to uBridge executable :param working_dir: working directory - :param host: host/address for this hypervisor - :param port: port for this hypervisor + :param transport: control channel transport — "unix" (-U) or "tcp" (-H) + :param host: host/address for the TCP transport (unused for "unix") """ - _instance_count = 1 + _instance_count = 0 - def __init__(self, project, path, working_dir, host, port=None): + def __init__(self, project, path, working_dir, transport, host=None): - if port is None: + self._project = project + self._path = path + self._working_dir = working_dir + + if transport == "unix": + # AF_UNIX control socket (-U). sun_path is capped at 107 bytes, so + # keep it under a private runtime dir — never under the project tree + # (per-node UUIDs would overflow it). + Hypervisor._instance_count += 1 + runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir() + socket_dir = os.path.join(runtime_dir, "gns3") + try: + os.makedirs(socket_dir, mode=0o700, exist_ok=True) + os.chmod(socket_dir, 0o700) + except OSError as e: + raise UbridgeError(f"Could not create uBridge socket directory {socket_dir}: {e}") + socket_path = os.path.join(socket_dir, f"ubridge-{Hypervisor._instance_count}.sock") + super().__init__(socket_path=socket_path) + else: + # TCP control channel (-H): let the OS find an unused local port. + port = None try: - port = None info = socket.getaddrinfo(host, 0, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE) if not info: raise UbridgeError(f"getaddrinfo returns an empty list on {host}") @@ -68,11 +88,8 @@ class Hypervisor(UBridgeHypervisor): break except OSError as e: raise UbridgeError(f"Could not find free port for the uBridge hypervisor: {e}") + super().__init__(host=host, port=port) - super().__init__(host, port) - self._project = project - self._path = path - self._working_dir = working_dir self._command = [] self._process = None self._stdout_file = "" @@ -214,6 +231,16 @@ class Hypervisor(UBridgeHypervisor): os.remove(self._stdout_file) except OSError as e: log.warning(f"could not delete temporary uBridge log file: {e}") + + # ubridge unlinks its AF_UNIX control socket on a clean exit; for the + # unix transport remove it here too so a killed process leaves no stale + # socket behind. The TCP transport has no socket_path. + if self._socket_path: + try: + os.unlink(self._socket_path) + except OSError: + pass + self._process = None self._started = False @@ -250,7 +277,10 @@ class Hypervisor(UBridgeHypervisor): """ command = [self._path] - command.extend(["-H", f"{self._host}:{self._port}"]) + if self._socket_path: + command.extend(["-U", self._socket_path]) + else: + command.extend(["-H", f"{self._host}:{self._port}"]) if log.getEffectiveLevel() == logging.DEBUG: command.extend(["-d", "1"]) return command diff --git a/gns3server/compute/ubridge/ubridge_hypervisor.py b/gns3server/compute/ubridge/ubridge_hypervisor.py index a44bf3834..83f765d16 100644 --- a/gns3server/compute/ubridge/ubridge_hypervisor.py +++ b/gns3server/compute/ubridge/ubridge_hypervisor.py @@ -28,20 +28,29 @@ log = logging.getLogger(__name__) class UBridgeHypervisor: """ - Creates a new connection to uBridge hypervisor. + Creates a new connection to a uBridge hypervisor control channel. - :param host: the hostname or ip address string of the uBridge hypervisor - :param port: the tcp port integer + Two transports, selected by which argument is set: + * ``socket_path`` -> AF_UNIX (``-U``), authenticated in-kernel via + SO_PEERCRED (ubridge accepts only its own UID; the compute process that + spawned it shares that UID). Recommended on Linux. + * ``host``/``port`` -> TCP (``-H``), retained for backward compatibility. + + :param socket_path: path to the uBridge AF_UNIX control socket (None for TCP) + :param host: TCP hostname/IP (None for AF_UNIX) + :param port: TCP port :param timeout: timeout integer for how long to wait for a response to commands sent to the - hypervisor (defaults to 30 seconds) + hypervisor (defaults to 30 seconds) """ # Used to parse Ubridge response codes error_re = re.compile(r"""^2[0-9]{2}-""") success_re = re.compile(r"""^1[0-9]{2}\s{1}""") - def __init__(self, host, port, timeout=30.0): + def __init__(self, socket_path=None, host=None, port=None, timeout=30.0): + # Exactly one transport is active: socket_path (AF_UNIX) or host/port (TCP). + self._socket_path = socket_path self._host = host self._port = port self._version = "N/A" @@ -54,22 +63,23 @@ class UBridgeHypervisor: Connects to the hypervisor. """ - # connect to a local address by default - # if listening to all addresses (IPv4 or IPv6) - if self._host == "0.0.0.0": - host = "127.0.0.1" - elif self._host == "::": - host = "::1" - else: - host = self._host - begin = time.time() connection_success = False last_exception = None while time.time() - begin < timeout: await asyncio.sleep(0.1) try: - self._reader, self._writer = await asyncio.open_connection(host, self._port) + if self._socket_path: + self._reader, self._writer = await asyncio.open_unix_connection(self._socket_path) + else: + # connect to a local address by default if listening on all addresses + if self._host == "0.0.0.0": + host = "127.0.0.1" + elif self._host == "::": + host = "::1" + else: + host = self._host + self._reader, self._writer = await asyncio.open_connection(host, self._port) except OSError as e: last_exception = e continue @@ -77,9 +87,9 @@ class UBridgeHypervisor: break if not connection_success: - raise UbridgeError(f"Couldn't connect to hypervisor on {host}:{self._port} :{last_exception}") + raise UbridgeError(f"Couldn't connect to hypervisor on {self.endpoint} :{last_exception}") else: - log.info(f"Connected to uBridge hypervisor on {host}:{self._port} after {time.time() - begin:.4f} seconds") + log.info(f"Connected to uBridge hypervisor on {self.endpoint} after {time.time() - begin:.4f} seconds") try: await asyncio.sleep(0.1) @@ -122,7 +132,7 @@ class UBridgeHypervisor: await self._writer.drain() self._writer.close() except OSError as e: - log.debug(f"Stopping hypervisor {self._host}:{self._port} {e}") + log.debug(f"Stopping hypervisor {self.endpoint} {e}") self._reader = self._writer = None async def reset(self): @@ -133,44 +143,17 @@ class UBridgeHypervisor: await self.send("hypervisor reset") @property - def port(self): + def endpoint(self): """ - Returns the port used to start the hypervisor. + Returns a human-readable control endpoint: the AF_UNIX socket path when + using -U, or host:port when using -H. Used for logging and errors. - :returns: port number (integer) + :returns: endpoint (string) """ - return self._port - - @port.setter - def port(self, port): - """ - Sets the port used to start the hypervisor. - - :param port: port number (integer) - """ - - self._port = port - - @property - def host(self): - """ - Returns the host (binding) used to start the hypervisor. - - :returns: host/address (string) - """ - - return self._host - - @host.setter - def host(self, host): - """ - Sets the host (binding) used to start the hypervisor. - - :param host: host/address (string) - """ - - self._host = host + if self._socket_path: + return self._socket_path + return f"{self._host}:{self._port}" @locking async def send(self, command): @@ -205,8 +188,8 @@ class UBridgeHypervisor: await self._writer.drain() except OSError as e: raise UbridgeError( - "Lost communication with {host}:{port} when sending command '{command}': {error}, uBridge process running: {run}".format( - host=self._host, port=self._port, command=command, error=e, run=self.is_running() + "Lost communication with {endpoint} when sending command '{command}': {error}, uBridge process running: {run}".format( + endpoint=self.endpoint, command=command, error=e, run=self.is_running() ) ) @@ -232,8 +215,8 @@ class UBridgeHypervisor: if not chunk: if retries > max_retries: raise UbridgeError( - "No data returned from {host}:{port} after sending command '{command}', uBridge process running: {run}".format( - host=self._host, port=self._port, command=command, run=self.is_running() + "No data returned from {endpoint} after sending command '{command}', uBridge process running: {run}".format( + endpoint=self.endpoint, command=command, run=self.is_running() ) ) else: @@ -244,8 +227,8 @@ class UBridgeHypervisor: buf += chunk.decode("utf-8") except OSError as e: raise UbridgeError( - "Lost communication with {host}:{port} after sending command '{command}': {error}, uBridge process running: {run}".format( - host=self._host, port=self._port, command=command, error=e, run=self.is_running() + "Lost communication with {endpoint} after sending command '{command}': {error}, uBridge process running: {run}".format( + endpoint=self.endpoint, command=command, error=e, run=self.is_running() ) ) @@ -255,8 +238,8 @@ class UBridgeHypervisor: continue except IndexError: raise UbridgeError( - "Could not communicate with {host}:{port} after sending command '{command}', uBridge process running: {run}".format( - host=self._host, port=self._port, command=command, run=self.is_running() + "Could not communicate with {endpoint} after sending command '{command}', uBridge process running: {run}".format( + endpoint=self.endpoint, command=command, run=self.is_running() ) ) diff --git a/gns3server/config_samples/gns3_server.conf b/gns3server/config_samples/gns3_server.conf index a8db5004c..04ed81e85 100644 --- a/gns3server/config_samples/gns3_server.conf +++ b/gns3server/config_samples/gns3_server.conf @@ -92,6 +92,11 @@ udp_end_port_range = 30000 ; uBridge executable location, default: search in PATH ;ubridge_path = ubridge +; uBridge control channel transport: "tcp" (-H host:port, default) or "unix" +; (-U socket_path; AF_UNIX + SO_PEERCRED, recommended on Linux for kernel-level +; peer authentication). TCP now binds loopback by default. +;ubridge_control_transport = tcp + ; Marker (traffic-insight) UDP sink: one listener per compute process that ; receives uBridge MARK signals from every uBridge on this host. ; marker_listen_host defaults to 127.0.0.1 because uBridge runs locally. diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index 851f0d4e3..af920c099 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -113,6 +113,16 @@ class ServerProtocol(str, Enum): https = "https" +class UbridgeControlTransport(str, Enum): + + # TCP control channel: -H host:port. ubridge now binds loopback by default, + # so this is reachable only locally. Retained for backward compatibility. + tcp = "tcp" + # AF_UNIX control channel: -U socket_path, authenticated in-kernel via + # SO_PEERCRED (ubridge accepts only its own UID). Recommended on Linux. + unix = "unix" + + class BuiltinSymbolTheme(str, Enum): classic = "Classic" @@ -154,6 +164,10 @@ class ServerSettings(BaseModel): udp_start_port_range: int = Field(10000, gt=0, le=65535) udp_end_port_range: int = Field(30000, gt=0, le=65535) ubridge_path: str = "ubridge" + # Transport for the uBridge hypervisor control channel. "tcp" (-H) is the + # historical default; "unix" (-U, AF_UNIX + SO_PEERCRED) is recommended on + # Linux for kernel-level peer authentication. + ubridge_control_transport: UbridgeControlTransport = UbridgeControlTransport.tcp # Marker (traffic-insight) UDP sink: one listener per compute process that # receives ubridge MARK signals from every ubridge on this host. The host # defaults to loopback because ubridge runs on the same host as the compute.