From 474dc1db649895ab4e311fd1e695e021c1be6832 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 8 Aug 2026 16:48:27 +0800 Subject: [PATCH] =?UTF-8?q?Prototype:=20vendor=20NOS=20Docker=20node=20sup?= =?UTF-8?q?port=20(SR=20Linux=20etc.)=20=E2=80=94=20docker=5Fexec=20consol?= =?UTF-8?q?e=20via=20Docker=20exec=20API=20(pty=20+=20hijacked=20HTTP=20+?= =?UTF-8?q?=20NAWS=20resize)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gns3server/compute/base_node.py | 2 +- gns3server/compute/docker/docker_vm.py | 194 ++++++++++++++++++++++++- gns3server/schemas/common.py | 1 + 3 files changed, 189 insertions(+), 8 deletions(-) diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index e2431ffa0..8e3188c84 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -520,7 +520,7 @@ class BaseNode: log.warning(f"Cannot open console WebSocket: node {self.name} is not started") return - if self._console_type not in ("telnet", "ssh"): + 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}' " diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index ef4210668..97a03bb1e 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -20,6 +20,7 @@ Docker container instance. import sys import asyncio +import json import shutil import psutil import shlex @@ -118,6 +119,13 @@ class DockerVM(BaseNode): self._console_websocket = None self._extra_hosts = extra_hosts self._extra_volumes = extra_volumes or [] + # Prototype knobs for vendor NOS containers (e.g. Nokia SR Linux), parsed + # from GNS3_-prefixed entries in the node's environment. GNS3_-prefixed vars + # are not forwarded to the container, so this stays host-side only. + self._gns3_init = True # GNS3_SKIP_INIT=1 -> False (use image entrypoint) + self._interface_names = [] # GNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,... + self._console_cmd = None # GNS3_CONSOLE_CMD=... (command for the docker_exec console) + self._console_exec_writer = None # raw socket to the docker exec console (for cleanup) self._memory = memory self._cpus = cpus self._permissions_fixed = True @@ -494,10 +502,33 @@ class DockerVM(BaseNode): params["Cmd"] = [] if len(params["Cmd"]) == 0 and len(params["Entrypoint"]) == 0: params["Cmd"] = ["/bin/sh"] - params["Entrypoint"].insert(0, "/gns3/init.sh") # FIXME /gns3/init.sh is not found? + # Prototype: parse GNS3_-prefixed env overrides for vendor NOS containers. + # GNS3_SKIP_INIT=1 -> don't prepend /gns3/init.sh (image runs its own) + # GNS3_INTERFACE_NAMES=a,b,.. -> rename injected interfaces, in adapter order + # GNS3_CONSOLE_CMD= -> command run via the "docker_exec" console type + if self._environment: + for _line in self._environment.splitlines(): + _line = _line.strip().rstrip(",") + if _line.startswith("GNS3_SKIP_INIT="): + # GNS3_SKIP_INIT=1 means do NOT prepend /gns3/init.sh + self._gns3_init = _line.split("=", 1)[1].strip().lower() not in ("1", "true", "yes") + elif _line.startswith("GNS3_INTERFACE_NAMES="): + self._interface_names = [ + n.strip() for n in _line.split("=", 1)[1].split(",") if n.strip() + ] + elif _line.startswith("GNS3_CONSOLE_CMD="): + self._console_cmd = _line.split("=", 1)[1].strip() - # Give the information to the container on how many interface should be inside - params["Env"].append(f"GNS3_MAX_ETHERNET=eth{self.adapters - 1}") + if self._gns3_init: + params["Entrypoint"].insert(0, "/gns3/init.sh") # FIXME /gns3/init.sh is not found? + + # Tell init.sh which last interface to wait for; honour the rename if any + # (no-op when init is skipped, but kept consistent). + if self._interface_names and self.adapters - 1 < len(self._interface_names): + last_ifname = self._interface_names[self.adapters - 1] + else: + last_ifname = f"eth{self.adapters - 1}" + params["Env"].append(f"GNS3_MAX_ETHERNET={last_ifname}") # Give the information to the container the list of volume path mounted params["Env"].append("GNS3_VOLUMES={}".format(":".join(self._volumes))) @@ -665,6 +696,9 @@ class DockerVM(BaseNode): if self._console_websocket: await self._console_websocket.close() self._console_websocket = None + if self._console_exec_writer: + self._console_exec_writer.close() + self._console_exec_writer = None await self._clean_servers() await self.manager.query("POST", f"containers/{self._cid}/start") @@ -698,6 +732,8 @@ class DockerVM(BaseNode): await self._start_console() elif self.console_type == "http" or self.console_type == "https": await self._start_http() + elif self.console_type == "docker_exec": + await self._start_docker_exec_console() if self.aux_type != "none": await self._start_aux() @@ -910,6 +946,145 @@ class DockerVM(BaseNode): except DockerError as e: log.warning(f"Could not resize the container TTY: {e}") + async def _start_docker_exec_console(self): + """ + Start a console that runs a command inside the container via the Docker + exec API, bridged to a telnet server. Intended for vendor NOS containers + (e.g. Nokia SR Linux) whose CLI is a separate TUI process not exposed on + PID 1's stdio. + + The exec is created with a pty (Tty:true) and started with a hijacked + raw HTTP request on the Docker unix socket (aiohttp's websocket client + cannot start a Docker exec; docker-py uses the same hijacked-HTTP + approach). The bidirectional byte stream is bridged to the telnet server + in binary mode so TUI escape sequences reach xterm.js intact, and the + client's terminal size (NAWS) is propagated to the exec pty via + `POST exec/{id}/resize`. + + The exec is created lazily on the first client connection (not when the + node starts) so the command's startup terminal probe — e.g. sr_cli / + prompt_toolkit cursor-position requests (CPR) — has a real client to + answer it; otherwise the probe runs before any xterm.js is attached and + the TUI degrades. The single exec is then shared (broadcast) by all + clients, matching GNS3's console model. Command from GNS3_CONSOLE_CMD. + """ + + command = self._console_cmd or "/bin/sh" + vm = self + manager = self.manager + cid = self._cid + + class _LazyExecTelnetServer(AsyncioTelnetServer): + """Telnet console whose docker exec (pty + command) is created on the + first client connection and then broadcast to all clients.""" + + def __init__(srv): + super().__init__( + reader=None, + writer=None, + binary=True, + echo=False, + naws=True, + window_size_changed_callback=srv._on_naws, + ) + srv._exec_id = None + srv._started = False + srv._lock = asyncio.Lock() + + async def _on_naws(srv, columns, rows): + # propagate the client's terminal size to the exec pty (no-op + # until the exec has been created on first connect). + if srv._exec_id: + try: + await manager.query( + "POST", + f"exec/{srv._exec_id}/resize", + params={"h": str(rows), "w": str(columns)}, + ) + except DockerError: + pass + + async def _create_exec(srv): + # create exec with a pty; run as root (vendor CLIs reject the + # image's default unprivileged user) and export TERM=xterm. + result = await manager.query( + "POST", + f"containers/{cid}/exec", + data={ + "AttachStdin": True, + "AttachStdout": True, + "AttachStderr": True, + "Tty": True, + "User": "root", + "Env": ["TERM=xterm"], + "Cmd": shlex.split(command), + }, + ) + srv._exec_id = result["Id"] + + # start the exec via a hijacked raw HTTP request on the Docker + # unix socket; with Tty:true the response body is a raw + # bidirectional pty byte stream (no multiplexing). + reader, writer = await asyncio.open_unix_connection(manager._server_url) + body = json.dumps({"Detach": False, "Tty": True}) + request = ( + f"POST /v{manager._api_version}/exec/{srv._exec_id}/start HTTP/1.1\r\n" + "Host: docker\r\n" + "Connection: Upgrade\r\n" + "Upgrade: tcp\r\n" + "Content-Type: application/json\r\n" + f"Content-Length: {len(body)}\r\n\r\n{body}" + ).encode() + writer.write(request) + await writer.drain() + try: + headers = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=5) + except (asyncio.IncompleteReadError, asyncio.TimeoutError) as e: + writer.close() + raise DockerError(f"Docker exec start failed: {e}") + status_line = headers.split(b"\r\n", 1)[0] + if b" 101 " not in status_line and b" 200 " not in status_line: + writer.close() + raise DockerError(f"Docker exec start rejected: {status_line.decode(errors='ignore')}") + + # wire the exec stream as this server's upstream and start the + # broadcast task. AsyncioTelnetServer.start() only starts the + # broadcast when a reader is set at construction time, so with a + # lazy upstream we start it manually here. + srv._reader = reader + srv._writer = writer + vm._console_exec_writer = writer # for stop() cleanup + srv._broadcast_task = asyncio.create_task(srv._broadcast_from_upstream()) + + async def client_connected_hook(srv): + await super().client_connected_hook() + async with srv._lock: + if not srv._started: + await srv._create_exec() + srv._started = True + try: + await srv._on_naws(80, 24) # initial size before NAWS + except Exception: + pass + # ask the TUI to (re)draw for the client that just connected. + if srv._writer: + try: + srv._writer.write(b"\x0c") # Ctrl-L -> TUI redraws + await srv._writer.drain() + except Exception: + pass + + telnet = _LazyExecTelnetServer() + try: + self._telnet_servers.append( + await telnet.start(self._manager.port_manager.console_host, self.console) + ) + except OSError as e: + raise DockerError( + f"Could not start console server on socket {self._manager.port_manager.console_host}:{self.console}: {e}" + ) + log.debug(f"Docker container '{self.name}' started docker_exec console (lazy) on {self.console}") + async def _start_console(self): """ Starts streaming the console via telnet or ssh @@ -1194,12 +1369,17 @@ class DockerVM(BaseNode): log.warning(f"Could not set MAC address {mac_address} on interface {adapter.host_ifc}") - log.debug(f"Move container {self.name} adapter {adapter.host_ifc} to namespace {self._namespace}") + # Interface name inside the container netns: honour GNS3_INTERFACE_NAMES + # (e.g. mgmt0, e1-1 for vendor NOS containers) else default eth{N}. + if self._interface_names and adapter_number < len(self._interface_names): + ifname = self._interface_names[adapter_number] + else: + ifname = f"eth{adapter_number}" + + log.debug(f"Move container {self.name} adapter {adapter.host_ifc} -> {ifname} in ns {self._namespace}") try: await self._ubridge_send( - "docker move_to_ns {ifc} {ns} eth{adapter}".format( - ifc=adapter.host_ifc, ns=self._namespace, adapter=adapter_number - ) + f"docker move_to_ns {adapter.host_ifc} {self._namespace} {ifname}" ) except UbridgeError as e: raise UbridgeNamespaceError(e) diff --git a/gns3server/schemas/common.py b/gns3server/schemas/common.py index ce37c337f..b15bc5201 100644 --- a/gns3server/schemas/common.py +++ b/gns3server/schemas/common.py @@ -61,6 +61,7 @@ class ConsoleType(str, Enum): spice = "spice" spice_agent = "spice+agent" none = "none" + docker_exec = "docker_exec" class AuxType(str, Enum):