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")
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

View File

@ -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

View File

@ -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()
)
)

View File

@ -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.

View File

@ -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.