refactor: reach unix-socket NIOs through the container root in /proc

The unix-socket NIO no longer requires the socket directory to be a
persisted volume: uBridge references the sockets through
/proc/<container-pid>/root<dir>/..., which is always well under the
107-byte sun_path cap (a project directory path alone exceeds it) and
leaves the sockets ephemeral in the container's own filesystem.

This replaces the runtime-dir symlink alias and its cleanup, and the
mount-time volume enforcement. IOLDockerVM now persists only /tmp/run
(the IOL working directory: startup-config + NVRAM) as a nested bind
instead of the whole /tmp; stale socket cleanup is gone with it, as
containers are recreated on every start.
This commit is contained in:
YueGuobin committed 2026-09-05 21:54:12 +08:00
1 parent 42e26717e6
commit 165f271bb8
4 files changed
+135 -252

No files matched your search

+20 -40
View File
@@ -25,22 +25,20 @@ and muxes the IOS console onto PID 1 stdio (works with the plain ``telnet``
console type; requires a TTY, which GNS3 always allocates).
Networking does not use the container's network namespace at all: the runner's
netiomux exposes per-interface AF_UNIX datagram sockets in ``/tmp``
(``s%02d.sock`` receive, ``c%02d.sock`` send — raw Ethernet frames), wired by
the generic ``GNS3_UNIX_SOCKET_NIO`` capability of VendorDockerVM. Because the
netio bus directory is private to the node's ``/tmp`` volume, the application
IDs are fixed constants with no cross-node collisions.
netiomux exposes per-interface AF_UNIX datagram sockets in the container's
``/tmp`` (``s%02d.sock`` receive, ``c%02d.sock`` send — raw Ethernet frames),
wired by the generic ``GNS3_UNIX_SOCKET_NIO`` capability of VendorDockerVM
(uBridge reaches them through the container's root in /proc). Because the
netio bus directory is private to the container's /tmp, the application IDs
are fixed constants with no cross-node collisions.
This class is selected by the ``GNS3_IOL_RUNNER=1`` environment marker.
"""
import glob
import json
import logging
import os
import shutil
from gns3server.compute.docker.docker_error import DockerHttp404Error
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
log = logging.getLogger(__name__)
@@ -58,7 +56,7 @@ class IOLDockerVM(VendorDockerVM):
OOM-killer will fire.
The marker itself forces ``GNS3_SKIP_INIT`` and the unix-socket NIO wiring,
and auto-adds the ``/config`` and ``/tmp`` persistent volumes, so a
and auto-adds the ``/config`` and ``/tmp/run`` persistent volumes, so a
template containing only ``GNS3_IOL_RUNNER=1`` is fully configured.
"""
@@ -90,13 +88,14 @@ class IOLDockerVM(VendorDockerVM):
def _persistent_volume_list(self, image_info, include_network_config=True):
"""
Override: the runner requires ``/config`` (its config file, generated
below) and ``/tmp`` (netiomux sockets, NETMAP, NVRAM) as persisted
volumes — /tmp because uBridge must reach the sockets on the host.
Auto-add both so a minimal template cannot be misconfigured.
below) and ``/tmp/run`` (its working directory: startup-config and
NVRAM live there — NETMAP and the netiomux sockets are ephemeral and
stay in the container's own /tmp). Auto-add both so a minimal
template cannot be misconfigured.
"""
volumes = super()._persistent_volume_list(image_info, include_network_config)
for needed in (self._IOL_CONFIG_DIR, "/tmp"):
for needed in (self._IOL_CONFIG_DIR, self._IOL_RUN_DIR):
if not any(needed == v or needed.startswith(v.rstrip("/") + "/") for v in volumes):
volumes.append(needed)
return volumes
@@ -119,47 +118,28 @@ class IOLDockerVM(VendorDockerVM):
async def _prepare_iol_runtime(self):
"""
Regenerate the node's runtime files before the container starts.
Regenerate the node's runtime files before the container starts:
* ``<working_dir>/tmp/run/`` must exist or the IOL process dies at
boot (the runner writes NETMAP there but does not create it).
* ``<working_dir>/config/iol-config.json`` is rewritten on every
start so adapter-count and memory changes take effect.
* Ephemeral sockets left by a previous (possibly SIGKILLed) run are
removed — the runner rebinds them on boot and would fail on a
stale file. ``tmp/run`` is never touched: startup-config and NVRAM
persist there.
"""
try:
state = await self._get_container_state()
except DockerHttp404Error:
state = "stopped"
The netiomux sockets and the netio bus directory need no cleanup:
they live in the container's own /tmp, which is fresh in every
container GNS3 creates (containers are recreated on each start).
"""
os.makedirs(os.path.join(self.working_dir, "tmp", "run"), exist_ok=True)
self._write_iol_config()
if state == "running":
# Idempotent start of a live node: the sockets belong to the
# running runner; base start() will return early.
return
tmp_dir = os.path.join(self.working_dir, "tmp")
for pattern in ("s??.sock", "c??.sock"):
for stale in glob.glob(os.path.join(tmp_dir, pattern)):
try:
os.unlink(stale)
except OSError:
pass
for netio_dir in glob.glob(os.path.join(tmp_dir, "netio*")):
shutil.rmtree(netio_dir, ignore_errors=True)
def _write_iol_config(self):
"""
Write the runner's config file on the host side of the /config volume.
The runner drops to user-id/group-id after its setup, so everything it
creates inside the volumes is owned by the server user (which is also
what makes the /tmp sockets reachable by uBridge).
creates is owned by the server user — which is also what lets an
unprivileged uBridge traverse /proc/<pid>/root to reach the
container's sockets.
"""
config = {
+26 -75
View File
@@ -32,7 +32,6 @@ import json
import logging
import os
import shutil
import tempfile
from gns3server.utils.asyncio import wait_for_file_creation
from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer
@@ -70,16 +69,16 @@ class VendorDockerVM(DockerVM):
socket files instead of a TAP interface moved into the container's
network namespace. For images whose network agent exposes, per adapter
``N``, a receive socket ``s%02d.sock`` and a send-to path ``c%02d.sock``
(raw Ethernet frames, one datagram per frame) inside a persisted volume
directory — e.g. Cisco CML's iol-runner (see IOLDockerVM). uBridge
binds ``c{N:02d}.sock`` (its receive side) and sends to
``s{N:02d}.sock``. No TAP is created, the container's network namespace
is untouched and the ``mac_address`` template field is ignored (the
image's agent owns the MAC scheme).
(raw Ethernet frames, one datagram per frame) inside the container —
e.g. Cisco CML's iol-runner (see IOLDockerVM). uBridge reaches the
sockets through the container's root in ``/proc`` (see
``_unix_socket_wiring_dir``), so no volume mount is required; it binds
``c{N:02d}.sock`` (its receive side) and sends to ``s{N:02d}.sock``.
No TAP is created, the container's network namespace is untouched and
the ``mac_address`` template field is ignored (the image's agent owns
the MAC scheme).
* ``GNS3_UNIX_SOCKET_DIR=<dir>`` — in-container directory holding the
socket files (default ``/tmp``). Must be a persisted volume
(extra_volumes) so uBridge can reach the sockets on the host; node
creation fails with an actionable error otherwise.
socket files (default ``/tmp``).
"""
def __init__(self, *args, **kwargs):
@@ -104,7 +103,6 @@ class VendorDockerVM(DockerVM):
self._stop_timeout = 60
self._unix_socket_nio = False
self._unix_socket_dir = "/tmp"
self._unix_socket_aliases = set()
if self._environment:
for _line in self._environment.splitlines():
_line = _line.strip().rstrip(",")
@@ -167,17 +165,6 @@ class VendorDockerVM(DockerVM):
are never shadowed by an empty mount.
"""
binds = super()._mount_binds(image_info)
if self._unix_socket_nio:
socket_dir = self._unix_socket_dir.rstrip("/")
if not any(
v.rstrip("/") == socket_dir or socket_dir.startswith(v.rstrip("/") + "/")
for v in self._volumes
):
raise DockerError(
f"GNS3_UNIX_SOCKET_NIO requires socket directory '{self._unix_socket_dir}' of "
f"container '{self._name}' to be a persisted volume (add it to extra_volumes) "
f"so uBridge can reach the sockets on the host"
)
if self._gns3_init:
return binds
binds = [b for b in binds if b.get("Target") != "/gns3volumes/etc/network"]
@@ -320,57 +307,20 @@ class VendorDockerVM(DockerVM):
return self._interface_names[adapter_number]
return f"eth{adapter_number}"
@property
def _unix_socket_host_dir(self):
async def _unix_socket_wiring_dir(self):
"""
Host-side path of the in-container unix-socket directory: the bind
source of the persisted volume it lives in.
"""
return os.path.join(self.working_dir, os.path.relpath(self._unix_socket_dir, "/"))
Host-side directory to reference in the uBridge unix-NIO commands:
the container's socket directory reached through its root in /proc.
def _unix_socket_wiring_dir(self):
"""
Directory to reference in the uBridge unix-NIO commands.
AF_UNIX paths are capped at 107 bytes (sun_path minus the NUL), and a
node volume directory (projects/<uuid>/project-files/docker/<uuid>/tmp)
alone is ~120 — uBridge would reject the NIO with "invalid file path
size". Long directories are therefore aliased through a symlink in the
runtime directory (same trick as the uBridge control socket). uBridge
resolves the symlink; the socket files themselves always live in the
persisted volume, and the container-side /tmp paths are unaffected.
The sockets live in the container's own filesystem (no volume mount
needed); /proc/<pid>/root/<dir> resolves to the same files uBridge
sees, from outside any namespace. This also keeps the path well under
the 107-byte sun_path cap — a node directory (projects/<uuid>/…)
alone exceeds it.
"""
host_dir = self._unix_socket_host_dir
if len(host_dir) + len("/c00.sock") <= 107:
return host_dir
runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir()
alias = os.path.join(runtime_dir, "gns3", f"unixio-{self.id}")
try:
os.makedirs(os.path.dirname(alias), mode=0o700, exist_ok=True)
if os.path.lexists(alias):
os.unlink(alias)
os.symlink(host_dir, alias)
except OSError as e:
raise DockerError(
f"Could not create unix-socket alias '{alias}' for container '{self._name}': {e}"
)
self._unix_socket_aliases.add(alias)
return alias
async def _stop_ubridge(self):
"""
Override: also drop the short-path aliases created for the unix-socket
NIO (the sockets themselves are removed by the image's agent / the
next start's cleanup).
"""
await super()._stop_ubridge()
for alias in self._unix_socket_aliases:
with contextlib.suppress(OSError):
os.unlink(alias)
self._unix_socket_aliases.clear()
pid = await self._get_namespace()
return f"/proc/{pid}/root{self._unix_socket_dir}"
async def _add_ubridge_connection(self, nio, adapter_number):
"""
@@ -379,15 +329,16 @@ class VendorDockerVM(DockerVM):
a TAP interface moved into the container's network namespace.
Per adapter N the image's network agent is expected to create, inside
GNS3_UNIX_SOCKET_DIR (a persisted volume, enforced by _mount_binds):
GNS3_UNIX_SOCKET_DIR:
* ``s{N:02d}.sock`` — its receive socket; frames sent there are
injected into guest interface N;
* ``c{N:02d}.sock`` — the path it sends guest-egress frames to.
uBridge binds the c-socket as its receive side and sends to the
s-socket. No TAP is allocated, the namespace is untouched and guest
MAC addresses are whatever the image's agent uses.
s-socket (both reached through /proc — see _unix_socket_wiring_dir).
No TAP is allocated, the namespace is untouched and guest MAC
addresses are whatever the image's agent uses.
"""
if not self._unix_socket_nio:
@@ -406,8 +357,7 @@ class VendorDockerVM(DockerVM):
await self._ubridge_send(f"bridge create {bridge_name}")
self._bridges.add(bridge_name)
host_dir = self._unix_socket_host_dir
wiring_dir = self._unix_socket_wiring_dir()
wiring_dir = await self._unix_socket_wiring_dir()
local_sock = os.path.join(wiring_dir, f"c{adapter_number:02d}.sock")
remote_sock = os.path.join(wiring_dir, f"s{adapter_number:02d}.sock")
@@ -424,7 +374,8 @@ class VendorDockerVM(DockerVM):
f"Socket '{remote_sock}' for adapter {adapter_number} of container "
f"'{self._name}' did not appear within 30 seconds. Check that the "
f"container's port count covers adapter {adapter_number} and that "
f"'{self._unix_socket_dir}' is bind-mounted from the node directory."
f"its network agent creates the per-adapter socket pair in "
f"'{self._unix_socket_dir}'."
)
await self._ubridge_send(f'bridge add_nio_unix {bridge_name} "{local_sock}" "{remote_sock}"')