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.
This commit is contained in:
YueGuobin 2026-07-31 22:27:16 +08:00
parent deba9f8409
commit 3d3e20f69e
No known key found for this signature in database
5 changed files with 111 additions and 76 deletions

View File

@ -926,13 +926,16 @@ class BaseNode:
raise NodeError("uBridge requires root access or the capability to interact with network adapters") raise NodeError("uBridge requires root access or the capability to interact with network adapters")
server_host = self._manager.config.settings.Server.host server_host = self._manager.config.settings.Server.host
transport = self._manager.config.settings.Server.ubridge_control_transport
if not self.ubridge: if not self.ubridge:
self._ubridge_hypervisor = Hypervisor(self._project, self.ubridge_path, self.working_dir, server_host) self._ubridge_hypervisor = Hypervisor(
log.info(f"Starting new uBridge hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port}") 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() await self._ubridge_hypervisor.start()
if self._ubridge_hypervisor: if self._ubridge_hypervisor:
log.info( 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() await self._ubridge_hypervisor.connect()
# Tell this uBridge where to send MARK signals and which node id to # 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(): 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() await self._ubridge_hypervisor.stop()
self._ubridge_hypervisor = None self._ubridge_hypervisor = None

View File

@ -20,9 +20,10 @@ Represents a uBridge hypervisor and starts/stops the associated uBridge process.
import sys import sys
import os import os
import socket
import subprocess import subprocess
import asyncio import asyncio
import socket import tempfile
import re import re
from gns3server.utils import parse_version from gns3server.utils import parse_version
@ -44,17 +45,36 @@ class Hypervisor(UBridgeHypervisor):
:param project: Project instance :param project: Project instance
:param path: path to uBridge executable :param path: path to uBridge executable
:param working_dir: working directory :param working_dir: working directory
:param host: host/address for this hypervisor :param transport: control channel transport — "unix" (-U) or "tcp" (-H)
:param port: port for this hypervisor :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: try:
port = None
info = socket.getaddrinfo(host, 0, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE) info = socket.getaddrinfo(host, 0, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE)
if not info: if not info:
raise UbridgeError(f"getaddrinfo returns an empty list on {host}") raise UbridgeError(f"getaddrinfo returns an empty list on {host}")
@ -68,11 +88,8 @@ class Hypervisor(UBridgeHypervisor):
break break
except OSError as e: except OSError as e:
raise UbridgeError(f"Could not find free port for the uBridge hypervisor: {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._command = []
self._process = None self._process = None
self._stdout_file = "" self._stdout_file = ""
@ -214,6 +231,16 @@ class Hypervisor(UBridgeHypervisor):
os.remove(self._stdout_file) os.remove(self._stdout_file)
except OSError as e: except OSError as e:
log.warning(f"could not delete temporary uBridge log file: {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._process = None
self._started = False self._started = False
@ -250,7 +277,10 @@ class Hypervisor(UBridgeHypervisor):
""" """
command = [self._path] 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: if log.getEffectiveLevel() == logging.DEBUG:
command.extend(["-d", "1"]) command.extend(["-d", "1"])
return command return command

View File

@ -28,20 +28,29 @@ log = logging.getLogger(__name__)
class UBridgeHypervisor: 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 Two transports, selected by which argument is set:
:param port: the tcp port integer * ``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 :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 # Used to parse Ubridge response codes
error_re = re.compile(r"""^2[0-9]{2}-""") error_re = re.compile(r"""^2[0-9]{2}-""")
success_re = re.compile(r"""^1[0-9]{2}\s{1}""") 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._host = host
self._port = port self._port = port
self._version = "N/A" self._version = "N/A"
@ -54,22 +63,23 @@ class UBridgeHypervisor:
Connects to the hypervisor. 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() begin = time.time()
connection_success = False connection_success = False
last_exception = None last_exception = None
while time.time() - begin < timeout: while time.time() - begin < timeout:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
try: 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: except OSError as e:
last_exception = e last_exception = e
continue continue
@ -77,9 +87,9 @@ class UBridgeHypervisor:
break break
if not connection_success: 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: 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: try:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@ -122,7 +132,7 @@ class UBridgeHypervisor:
await self._writer.drain() await self._writer.drain()
self._writer.close() self._writer.close()
except OSError as e: 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 self._reader = self._writer = None
async def reset(self): async def reset(self):
@ -133,44 +143,17 @@ class UBridgeHypervisor:
await self.send("hypervisor reset") await self.send("hypervisor reset")
@property @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 if self._socket_path:
return self._socket_path
@port.setter return f"{self._host}:{self._port}"
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
@locking @locking
async def send(self, command): async def send(self, command):
@ -205,8 +188,8 @@ class UBridgeHypervisor:
await self._writer.drain() await self._writer.drain()
except OSError as e: except OSError as e:
raise UbridgeError( raise UbridgeError(
"Lost communication with {host}:{port} when sending command '{command}': {error}, uBridge process running: {run}".format( "Lost communication with {endpoint} when sending command '{command}': {error}, uBridge process running: {run}".format(
host=self._host, port=self._port, command=command, error=e, run=self.is_running() endpoint=self.endpoint, command=command, error=e, run=self.is_running()
) )
) )
@ -232,8 +215,8 @@ class UBridgeHypervisor:
if not chunk: if not chunk:
if retries > max_retries: if retries > max_retries:
raise UbridgeError( raise UbridgeError(
"No data returned from {host}:{port} after sending command '{command}', uBridge process running: {run}".format( "No data returned from {endpoint} after sending command '{command}', uBridge process running: {run}".format(
host=self._host, port=self._port, command=command, run=self.is_running() endpoint=self.endpoint, command=command, run=self.is_running()
) )
) )
else: else:
@ -244,8 +227,8 @@ class UBridgeHypervisor:
buf += chunk.decode("utf-8") buf += chunk.decode("utf-8")
except OSError as e: except OSError as e:
raise UbridgeError( raise UbridgeError(
"Lost communication with {host}:{port} after sending command '{command}': {error}, uBridge process running: {run}".format( "Lost communication with {endpoint} after sending command '{command}': {error}, uBridge process running: {run}".format(
host=self._host, port=self._port, command=command, error=e, run=self.is_running() endpoint=self.endpoint, command=command, error=e, run=self.is_running()
) )
) )
@ -255,8 +238,8 @@ class UBridgeHypervisor:
continue continue
except IndexError: except IndexError:
raise UbridgeError( raise UbridgeError(
"Could not communicate with {host}:{port} after sending command '{command}', uBridge process running: {run}".format( "Could not communicate with {endpoint} after sending command '{command}', uBridge process running: {run}".format(
host=self._host, port=self._port, command=command, run=self.is_running() endpoint=self.endpoint, command=command, run=self.is_running()
) )
) )

View File

@ -92,6 +92,11 @@ udp_end_port_range = 30000
; uBridge executable location, default: search in PATH ; uBridge executable location, default: search in PATH
;ubridge_path = ubridge ;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 ; Marker (traffic-insight) UDP sink: one listener per compute process that
; receives uBridge MARK signals from every uBridge on this host. ; receives uBridge MARK signals from every uBridge on this host.
; marker_listen_host defaults to 127.0.0.1 because uBridge runs locally. ; marker_listen_host defaults to 127.0.0.1 because uBridge runs locally.

View File

@ -113,6 +113,16 @@ class ServerProtocol(str, Enum):
https = "https" 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): class BuiltinSymbolTheme(str, Enum):
classic = "Classic" classic = "Classic"
@ -154,6 +164,10 @@ class ServerSettings(BaseModel):
udp_start_port_range: int = Field(10000, gt=0, le=65535) udp_start_port_range: int = Field(10000, gt=0, le=65535)
udp_end_port_range: int = Field(30000, gt=0, le=65535) udp_end_port_range: int = Field(30000, gt=0, le=65535)
ubridge_path: str = "ubridge" 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 # Marker (traffic-insight) UDP sink: one listener per compute process that
# receives ubridge MARK signals from every ubridge on this host. The host # 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. # defaults to loopback because ubridge runs on the same host as the compute.