From 474dc1db649895ab4e311fd1e695e021c1be6832 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 8 Aug 2026 16:48:27 +0800 Subject: [PATCH 01/15] =?UTF-8?q?Prototype:=20vendor=20NOS=20Docker=20node?= =?UTF-8?q?=20support=20(SR=20Linux=20etc.)=20=E2=80=94=20docker=5Fexec=20?= =?UTF-8?q?console=20via=20Docker=20exec=20API=20(pty=20+=20hijacked=20HTT?= =?UTF-8?q?P=20+=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): From 486178d05c14ff0b08b5f07679e1c4476e89557f Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 12 Aug 2026 19:14:44 +0800 Subject: [PATCH 02/15] docs: document the docker_exec console and vendor NOS Docker knobs --- docs/features/docker-exec-console.md | 209 +++++++++++++++++++++++++ gns3server/compute/docker/docker_vm.py | 23 ++- 2 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 docs/features/docker-exec-console.md diff --git a/docs/features/docker-exec-console.md b/docs/features/docker-exec-console.md new file mode 100644 index 000000000..a6d6cb839 --- /dev/null +++ b/docs/features/docker-exec-console.md @@ -0,0 +1,209 @@ + + +> This documentation is organized by AI with reference to actual code. AI can make mistakes — please verify against the source code when in doubt. + + +# Docker exec Console (Vendor NOS Containers) + +## Overview + +GNS3 Docker nodes normally expose their console by attaching to the container's +PID 1 stdio. That works for CLIs that run as PID 1 (e.g. FRR's `vtysh`), but it +does **not** work for vendor NOS containers (Nokia SR Linux, Arista cEOS, +Juniper cRPD, …) whose CLI is a separate, full-screen TUI process that is *not* +on PID 1. For those, attaching to PID 1 only shows boot logs and never yields a +CLI prompt. + +The `docker_exec` console type solves this. It runs a chosen command inside the +running container via the Docker exec API (with a pty) and bridges it to the +GNS3 console, so the vendor's native TUI CLI renders in the Web UI (xterm.js) +exactly as if you had run `docker exec -it ` in a real +terminal. + +Two companion environment knobs (`GNS3_SKIP_INIT`, `GNS3_INTERFACE_NAMES`) make +the container itself boot and wire correctly for vendor NOS images. Together +they let a vendor NOS run as a first-class GNS3 Docker router node. + +> Prototype status: the knobs are environment-driven and intentionally avoid +> schema changes, so existing Docker nodes (FRR, ipterm, …) are unaffected. +> `console_type: "docker_exec"` is added to the `ConsoleType` enum. + +## The three environment knobs + +All three are read from the node's `environment` field. Entries prefixed with +`GNS3_` are **not** forwarded into the container (existing GNS3 behaviour), so +they stay host-side configuration. + +| Variable | Purpose | +|----------|---------| +| `GNS3_SKIP_INIT=1` | Do **not** prepend `/gns3/init.sh` to the entrypoint. Vendor NOS images must run their own entrypoint (e.g. SR Linux's `sr_linux`); GNS3's init script (busybox bootstrap, `ifup`, eth wait) interferes with them. | +| `GNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3` | Rename the injected interfaces in adapter order instead of the default `eth{N}`. SR Linux expects `mgmt0` + `e1-N`; without this it does not recognise its datapath. | +| `GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli` | Command run by the `docker_exec` console inside the container. | + +## The `docker_exec` console type + +Setting `console_type: "docker_exec"` makes the node's primary console port run +`_start_docker_exec_console()` instead of the attach-to-PID-1 path. + +### Architecture + +```mermaid +graph LR + A[Web UI xterm.js] -->|console WS| B[GNS3 Compute telnet server] + B -->|binary pty stream| C[Docker exec API] + C -->|Tty:true pty| D[sr_cli / vendor CLI] + A -.->|NAWS size| B + B -.->|POST exec/.../resize| C +``` + +The console uses GNS3's **existing shared/broadcast telnet model**: a single +exec instance (one CLI session) is broadcast to every console client, exactly +like the primary console shares one PID 1. There is deliberately **no +per-client session isolation** — this matches how every other GNS3 console +behaves. + +### Implementation + +**File**: `gns3server/compute/docker/docker_vm.py` — `_start_docker_exec_console()` + +A small subclass `_LazyExecTelnetServer(AsyncioTelnetServer)` implements the +console. Key points: + +1. **Lazy exec creation.** The exec is created on the **first client + connection** (`client_connected_hook`), not when the node starts. This is + essential: vendor CLIs (e.g. `sr_cli` via `prompt_toolkit`) send a + cursor-position request (`\e[6n`, CPR) during startup and block waiting for + the terminal's answer. If the exec starts at node-start time there is no + xterm.js client to answer, the probe times out, and the TUI degrades (no + status bar, "Terminal doesn't support CPR" warning). Creating the exec on + first connect means the probe runs with a real xterm.js attached, which + answers CPR → full TUI. After creation the exec is shared by all clients. + +2. **Exec API with a pty.** `POST containers/{cid}/exec` with + `Tty: true`, `User: "root"` (vendor CLIs reject the image's default + unprivileged user — SR Linux returns *"User 'user' is not authorized to use + CLI"* otherwise), and `Env: ["TERM=xterm"]` (the TUI library needs a + recognised terminal). + +3. **Hijacked raw-HTTP start.** The exec is started with + `POST exec/{eid}/start` sent as a raw HTTP upgrade over the Docker unix + socket (`asyncio.open_unix_connection`), the same approach docker-py uses. + This is required because aiohttp's websocket client (`ws_connect`) is + rejected by Docker's exec-start endpoint (HTTP 400), while a raw POST + upgrade succeeds (101). With `Tty:true` the response body is a raw, + non-multiplexed bidirectional pty byte stream — no frame demux needed. + +4. **NAWS → exec resize.** The telnet server runs with `naws=True`; the + `window_size_changed_callback` calls `POST exec/{eid}/resize?h=&w=` so the + TUI lays out for the xterm.js window size. + +5. **Binary passthrough + redraw.** `binary=True` so TUI escape sequences reach + xterm.js intact; `echo=False` (the pty echoes). On every client (re)connect + a `Ctrl-L` (`\x0c`) is sent to the pty so a TUI that already drew its + screen for a previous client redraws for the new one (otherwise a + reconnect shows a blank screen until the next output). + +**File**: `gns3server/compute/base_node.py` — the console WebSocket guard now +allows `docker_exec` (alongside `telnet`/`ssh`), since the WS bridge connects to +the console TCP port exactly as it does for telnet. + +### Why earlier approaches failed (context) + +- `script` + `docker exec -it`: the `script` pty had size 0 (no NAWS) → the TUI + could not lay out → blank. +- `docker exec -i` (no `-t`) + `sr_cli -d` (dumb mode): line-mode output was + block-buffered and visually messy. +- Direct pipe relay: telnet `CRLF` polluted line input. + +The exec-API approach fixes all of these: a real pty (`Tty:true`), a real size +(NAWS resize), and a real terminal emulator (xterm.js answering CPR). + +## Configuration + +### SR Linux node example + +```json +{ + "name": "srlinux-1", + "node_type": "docker", + "image": "ghcr.io/nokia/srlinux:latest", + "adapters": 4, + "console_type": "docker_exec", + "start_command": "sudo -E bash -c 'touch /.dockerenv && /opt/srlinux/bin/sr_linux'", + "environment": "GNS3_SKIP_INIT=1\nGNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3\nGNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli" +} +``` + +- `start_command` is the SR Linux launch line (as used by containerlab). +- Connect the node's ports as usual — links still use GNS3's UDP NIO datapath + (container-agnostic); the rename only affects the in-container interface name. +- For the Web UI port **labels** to match (display `mgmt0`/`e1-1` instead of + `Ethernet0..3`), set `custom_adapters` per port + (`{"adapter_number": 0, "port_name": "mgmt0"}`, …). Port labels are a + controller-side concept, independent of the compute-side interface rename. + +### Persistent state + +For SR Linux, persist `/etc/opt/srlinux` (config / AAA users / TLS certs) by +adding it to the node's `extra_volumes`. `/var/opt/srlinux` does **not** exist +on current SR Linux images; `/var/log/srlinux` holds logs (optional). + +## Troubleshooting + +**1. Console shows only boot logs, no CLI** +- You are on the primary attach console. Set `console_type: "docker_exec"` and + use `GNS3_CONSOLE_CMD` to point at the vendor CLI. + +**2. `User '...' is not authorized to use CLI`** +- The exec must run as root. The implementation sets `User: "root"`; if you + fork it, keep that. + +**3. `Terminal doesn't support cursor position requests (CPR)`** +- This means the exec was started without an xterm.js client connected (the + startup probe had no one to answer). The lazy-start design avoids this; if you + see it, ensure the exec is created on first connect, not at node start. + +**4. Reconnecting the Web console shows a blank screen** +- A `Ctrl-L` is sent on each connect to force a TUI redraw. If the TUI does not + redraw, verify the `client_connected_hook` still writes `\x0c` to the pty. + +**5. `aiohttp WSServerHandshakeError: 400` on exec start** +- Do **not** use the websocket client to start an exec. Use the hijacked raw + HTTP upgrade over the unix socket (see Implementation). + +**6. SR Linux data interfaces stay down** +- SR Linux defaults its data ports to `admin-state disable`; enable them in the + CLI (`interface ethernet-1/1 admin-state enable`) and bind the interface to a + network-instance before ping works. This is SR Linux behaviour, not a GNS3 + issue. + +## Limitations + +1. **Shared session (broadcast).** All console clients share one CLI session + and can see each other's input — identical to GNS3's existing primary + console model. There is no per-client independent session. +2. **`reset_console` not wired.** The console-reset action only handles + `telnet`/`ssh`; it is a no-op for `docker_exec` (non-blocking; reconnect + works fine). +3. **Prototype knobs.** `GNS3_SKIP_INIT` / `GNS3_INTERFACE_NAMES` / + `GNS3_CONSOLE_CMD` are environment-driven; they are not yet first-class node + schema fields and are not declared in the appliance (`gns3a`) schema. + +## References + +- `gns3server/compute/docker/docker_vm.py` — `_start_docker_exec_console`, + `_LazyExecTelnetServer`, `_add_ubridge_connection` (interface rename), + `create()` (env parsing, skip-init, `GNS3_MAX_ETHERNET`). +- `gns3server/compute/base_node.py` — console WebSocket guard. +- `gns3server/schemas/common.py` — `ConsoleType.docker_exec`. +- containerlab `nodes/srl/srl.go` — reference for SR Linux launch command and + interface naming. + +## Version History + +| Version | Date | Changes | +|---------|------|---------| +| 1.0 | 2026-08-12 | Initial documentation of the `docker_exec` console and vendor NOS knobs. | diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 97a03bb1e..0279a1774 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -990,6 +990,7 @@ class DockerVM(BaseNode): srv._exec_id = None srv._started = False srv._lock = asyncio.Lock() + srv._log_name = f"docker_exec console '{vm.name}'" async def _on_naws(srv, columns, rows): # propagate the client's terminal size to the exec pty (no-op @@ -1004,6 +1005,13 @@ class DockerVM(BaseNode): except DockerError: pass + async def run(srv, network_reader, network_writer): + """Catch and log any exception that kills the client session.""" + try: + await super().run(network_reader, network_writer) + except Exception as exc: + log.warning(f"{srv._log_name}: client session terminated: {exc}", exc_info=True) + 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. @@ -1021,6 +1029,7 @@ class DockerVM(BaseNode): }, ) srv._exec_id = result["Id"] + log.info(f"{srv._log_name}: exec created ({srv._exec_id})") # start the exec via a hijacked raw HTTP request on the Docker # unix socket; with Tty:true the response body is a raw @@ -1043,6 +1052,7 @@ class DockerVM(BaseNode): writer.close() raise DockerError(f"Docker exec start failed: {e}") status_line = headers.split(b"\r\n", 1)[0] + log.info(f"{srv._log_name}: hijacked start -> {status_line.decode(errors='ignore')}") 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')}") @@ -1055,12 +1065,18 @@ class DockerVM(BaseNode): srv._writer = writer vm._console_exec_writer = writer # for stop() cleanup srv._broadcast_task = asyncio.create_task(srv._broadcast_from_upstream()) + log.info(f"{srv._log_name}: broadcast task started, upstream wired, ready") async def client_connected_hook(srv): await super().client_connected_hook() + log.info(f"{srv._log_name}: client connected, lazy_started={srv._started}") async with srv._lock: if not srv._started: - await srv._create_exec() + try: + await srv._create_exec() + except Exception as exc: + log.warning(f"{srv._log_name}: failed to create exec: {exc}", exc_info=True) + raise srv._started = True try: await srv._on_naws(80, 24) # initial size before NAWS @@ -1071,8 +1087,9 @@ class DockerVM(BaseNode): try: srv._writer.write(b"\x0c") # Ctrl-L -> TUI redraws await srv._writer.drain() - except Exception: - pass + except Exception as exc: + log.warning(f"{srv._log_name}: Ctrl-L write failed: {exc}") + log.info(f"{srv._log_name}: client_connected_hook done") telnet = _LazyExecTelnetServer() try: From 511c52330b10463e71615b65728cb3981675ba3b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 12 Aug 2026 22:07:03 +0800 Subject: [PATCH 03/15] docker_exec console: wrap exec command in while-true loop so sr_cli restarts on quit --- gns3server/compute/docker/docker_vm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 0279a1774..5bfd3811f 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -1025,7 +1025,7 @@ class DockerVM(BaseNode): "Tty": True, "User": "root", "Env": ["TERM=xterm"], - "Cmd": shlex.split(command), + "Cmd": ["sh", "-c", f"while true; do {command}; done"], }, ) srv._exec_id = result["Id"] From 5388fd3796041c0402ec0ffd2e7eb352453b2e47 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 12 Aug 2026 22:51:26 +0800 Subject: [PATCH 04/15] refactor: move vendor NOS Docker support into VendorDockerVM subclass Extract the docker_exec console and GNS3_* prototype knobs (SKIP_INIT, INTERFACE_NAMES, CONSOLE_CMD) from DockerVM into a VendorDockerVM subclass. DockerVM is restored to its 3.1 baseline plus four small extension hooks (_prepare_init_and_interface_env, _start_console_server, _get_container_ifname, _cleanup_console_resources) that are pure refactorings with zero behaviour change for existing nodes. VendorDockerVM additionally replicates init.sh's volume persistence (bind-mount /gns3volumes over the in-container path) via docker exec for containers that skip init.sh, so vendor NOS config (e.g. /etc/opt/srlinux) survives node stop/start. The Docker manager selects VendorDockerVM when console_type == docker_exec; all other nodes keep using DockerVM unchanged. --- gns3server/compute/docker/__init__.py | 11 + gns3server/compute/docker/docker_vm.py | 246 +++---------- gns3server/compute/docker/vendor_docker_vm.py | 330 ++++++++++++++++++ 3 files changed, 380 insertions(+), 207 deletions(-) create mode 100644 gns3server/compute/docker/vendor_docker_vm.py diff --git a/gns3server/compute/docker/__init__.py b/gns3server/compute/docker/__init__.py index f076e556c..ab095084f 100644 --- a/gns3server/compute/docker/__init__.py +++ b/gns3server/compute/docker/__init__.py @@ -32,6 +32,7 @@ from gns3server.config import Config from gns3server.utils.asyncio import locking from gns3server.compute.base_manager import BaseManager from gns3server.compute.docker.docker_vm import DockerVM +from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM from gns3server.compute.docker.docker_error import DockerError, DockerHttp304Error, DockerHttp404Error, DockerHttp409Error log = logging.getLogger(__name__) @@ -59,6 +60,16 @@ class Docker(BaseManager): self._session = None self._api_version = DOCKER_MINIMUM_API_VERSION + def _select_node_class(self, **kwargs): + """Select the node class based on console_type.""" + if kwargs.get("console_type") == "docker_exec": + return VendorDockerVM + return DockerVM + + async def create_node(self, name, project_id, node_id, *args, **kwargs): + self._NODE_CLASS = self._select_node_class(**kwargs) + return await super().create_node(name, project_id, node_id, *args, **kwargs) + @staticmethod async def install_busybox(dst_dir): diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 5bfd3811f..804039216 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -20,7 +20,6 @@ Docker container instance. import sys import asyncio -import json import shutil import psutil import shlex @@ -119,13 +118,6 @@ 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 @@ -441,6 +433,16 @@ class DockerVM(BaseNode): """.format(adapter=adapter, hostname=self._name)) return path + def _prepare_init_and_interface_env(self, params): + """ + Prepare the init-script entrypoint and GNS3_MAX_ETHERNET env var. + May be overridden by subclasses (e.g. VendorDockerVM) to skip init.sh + or rename injected interfaces. + """ + params["Entrypoint"].insert(0, "/gns3/init.sh") # FIXME /gns3/init.sh is not found? + # Give the information to the container on how many interface should be inside + params["Env"].append(f"GNS3_MAX_ETHERNET=eth{self.adapters - 1}") + async def create(self): """ Creates the Docker container. @@ -502,33 +504,7 @@ class DockerVM(BaseNode): params["Cmd"] = [] if len(params["Cmd"]) == 0 and len(params["Entrypoint"]) == 0: params["Cmd"] = ["/bin/sh"] - # 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() - - 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}") + self._prepare_init_and_interface_env(params) # Give the information to the container the list of volume path mounted params["Env"].append("GNS3_VOLUMES={}".format(":".join(self._volumes))) @@ -696,9 +672,7 @@ 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 + self._cleanup_console_resources() await self._clean_servers() await self.manager.query("POST", f"containers/{self._cid}/start") @@ -728,12 +702,7 @@ class DockerVM(BaseNode): log.error(line) raise DockerError(logdata) - if self.console_type in ("telnet", "ssh"): - 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() + await self._start_console_server() if self.aux_type != "none": await self._start_aux() @@ -746,6 +715,16 @@ class DockerVM(BaseNode): ) ) + async def _start_console_server(self): + """ + Dispatch the console server start based on console_type. + May be overridden to add extra console types (e.g. docker_exec). + """ + if self.console_type in ("telnet", "ssh"): + await self._start_console() + elif self.console_type == "http" or self.console_type == "https": + await self._start_http() + async def _start_aux(self): """ Start an auxiliary console @@ -946,162 +925,6 @@ 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() - srv._log_name = f"docker_exec console '{vm.name}'" - - 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 run(srv, network_reader, network_writer): - """Catch and log any exception that kills the client session.""" - try: - await super().run(network_reader, network_writer) - except Exception as exc: - log.warning(f"{srv._log_name}: client session terminated: {exc}", exc_info=True) - - 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": ["sh", "-c", f"while true; do {command}; done"], - }, - ) - srv._exec_id = result["Id"] - log.info(f"{srv._log_name}: exec created ({srv._exec_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] - log.info(f"{srv._log_name}: hijacked start -> {status_line.decode(errors='ignore')}") - 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()) - log.info(f"{srv._log_name}: broadcast task started, upstream wired, ready") - - async def client_connected_hook(srv): - await super().client_connected_hook() - log.info(f"{srv._log_name}: client connected, lazy_started={srv._started}") - async with srv._lock: - if not srv._started: - try: - await srv._create_exec() - except Exception as exc: - log.warning(f"{srv._log_name}: failed to create exec: {exc}", exc_info=True) - raise - 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 as exc: - log.warning(f"{srv._log_name}: Ctrl-L write failed: {exc}") - log.info(f"{srv._log_name}: client_connected_hook done") - - 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 @@ -1204,6 +1027,13 @@ class DockerVM(BaseNode): await self.manager.query("POST", f"containers/{self._cid}/restart") log.debug("Docker container '{name}' [{image}] restarted".format(name=self._name, image=self._image)) + def _cleanup_console_resources(self): + """ + Clean up console resources before restart. + May be overridden (e.g. VendorDockerVM closes the exec pty socket). + """ + pass + async def _clean_servers(self): """ Clean the list of running console servers @@ -1224,6 +1054,7 @@ class DockerVM(BaseNode): if self._console_websocket: await self._console_websocket.close() self._console_websocket = None + self._cleanup_console_resources() await self._clean_servers() await self._stop_ubridge() @@ -1338,6 +1169,13 @@ class DockerVM(BaseNode): log.debug(f"Docker error when closing: {str(e)}") return + def _get_container_ifname(self, adapter_number): + """ + Return the interface name used inside the container for *adapter_number*. + May be overridden to provide custom naming (e.g. mgmt0, e1-1). + """ + return f"eth{adapter_number}" + async def _add_ubridge_connection(self, nio, adapter_number): """ Creates a connection in uBridge. @@ -1386,13 +1224,7 @@ class DockerVM(BaseNode): log.warning(f"Could not set MAC address {mac_address} on interface {adapter.host_ifc}") - # 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}" - + ifname = self._get_container_ifname(adapter_number) log.debug(f"Move container {self.name} adapter {adapter.host_ifc} -> {ifname} in ns {self._namespace}") try: await self._ubridge_send( diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py new file mode 100644 index 000000000..cd03fa0fe --- /dev/null +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -0,0 +1,330 @@ +# +# Copyright (C) 2025 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Vendor NOS Docker container subclass. + +Provides support for vendor NOS containers (Nokia SR Linux, Arista cEOS, +Juniper cRPD, …) whose CLI is a separate TUI process not exposed on PID 1 +stdio, and whose boot model requires skipping GNS3's init.sh bootstrapping. + +The subclass is selected automatically when ``console_type == "docker_exec"``. +All vendor features are opt-in — without GNS3_* environment variables the +container behaves identically to DockerVM. +""" + +import asyncio +import json +import logging + +from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer +from gns3server.compute.docker.docker_vm import DockerVM +from gns3server.compute.docker.docker_error import DockerError + +log = logging.getLogger(__name__) + + +class VendorDockerVM(DockerVM): + """ + DockerVM subclass for vendor NOS containers. + + Opt-in features, activated by GNS3_-prefixed environment entries + (host-side only — GNS3_ entries are never forwarded into the container): + + * ``GNS3_SKIP_INIT=1`` — do not prepend /gns3/init.sh; the container runs + its own entrypoint (e.g. SR Linux's ``sr_linux``). Init.sh's volume + persistence (bind-mount /gns3volumes → target) is replicated via + ``docker exec`` after the container starts. + * ``GNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2`` — rename injected interfaces + (adapter order) instead of default ``eth{N}``. + * ``GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli`` — command run inside the + container by the ``docker_exec`` console (defaults to ``/bin/sh``). + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Prototype knobs — parsed from GNS3_-prefixed entries in create(). + # Parse eagerly so _get_container_ifname can return the right name. + self._gns3_init = True + self._interface_names = [] + self._console_cmd = None + self._console_exec_writer = None + + if self._environment: + for _line in self._environment.splitlines(): + _line = _line.strip().rstrip(",") + if _line.startswith("GNS3_SKIP_INIT="): + 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() + + # ---- hook overrides --------------------------------------------------- + + def _prepare_init_and_interface_env(self, params): + """ + Override: conditionally prepend init.sh, and honour + GNS3_INTERFACE_NAMES (if set) for GNS3_MAX_ETHERNET. + """ + if self._gns3_init: + params["Entrypoint"].insert(0, "/gns3/init.sh") + + # 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}") + + def _get_container_ifname(self, adapter_number): + """ + Override: honour GNS3_INTERFACE_NAMES (e.g. mgmt0, e1-1) in adapter + order; fall back to eth{N} for unlisted ports. + """ + if self._interface_names and adapter_number < len(self._interface_names): + return self._interface_names[adapter_number] + return f"eth{adapter_number}" + + def _cleanup_console_resources(self): + """ + Override: close the docker-exec pty socket, if any, so the next + restart or stop doesn't leak it. + """ + if self._console_exec_writer: + try: + self._console_exec_writer.close() + except Exception: + pass + self._console_exec_writer = None + + async def start(self): + await super().start() + if self.status == "started" and not self._gns3_init: + await self._setup_skip_init_volumes() + + async def _setup_skip_init_volumes(self): + """ + Replicate the volume-persistence portion of init.sh (lines 35–52) for + containers that skip init.sh (GNS3_SKIP_INIT=1). + + On first start the container's original files are seeded into the + persistent host directory; on subsequent starts the persisted data + is bind-mounted over the in-container path so writes land on the host. + Permission-changes recorded by _fix_permissions at the previous + stop are restored (best-effort). + """ + for volume in self._volumes: + vol_target = f"/gns3volumes{volume}" + # fmt: off + script = ( + f'mkdir -p "{volume}" && ' + f'if [ ! -f "{vol_target}/.gns3_perms" ]; then ' + f' /gns3/bin/busybox cp -a "{volume}/." "{vol_target}/" 2>/dev/null; ' + f' /gns3/bin/busybox touch "{vol_target}/.gns3_perms"; ' + f'fi && ' + f'/gns3/bin/busybox mount --bind "{vol_target}" "{volume}" && ' + f'while IFS=: read -r PERMS OWNER GROUP FILE; do ' + f' [ -L "$FILE" ] || /gns3/bin/busybox chmod "$PERMS" "$FILE" 2>/dev/null; ' + f' /gns3/bin/busybox chown -h "$OWNER:$GROUP" "$FILE" 2>/dev/null; ' + f'done < "{volume}/.gns3_perms"' + ) + # fmt: on + try: + process = await asyncio.subprocess.create_subprocess_exec( + "docker", + "exec", + self._cid, + "sh", + "-c", + script, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + if process.returncode != 0: + err = stderr.decode(errors="replace").strip() + log.warning( + "Volume setup for '%s' on container '%s' returned %d: %s", + volume, self._name, process.returncode, err, + ) + else: + log.info("Volume '%s' bound to persistent storage for '%s'", volume, self._name) + except OSError as e: + log.warning( + "Could not setup volume '%s' for container '%s': %s", volume, self._name, e + ) + + async def _start_console_server(self): + """ + Override: add the ``docker_exec`` console type alongside the + telnet/ssh/http types supported by the base class. + """ + if self.console_type == "docker_exec": + await self._start_docker_exec_console() + else: + await super()._start_console_server() + + # ---- docker_exec console implementation -------------------------------- + + 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 lazily on the first client connection (not when the + node starts) so the command's startup terminal probe has a real xterm.js + client to answer it (CPR / prompt_toolkit). 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() + srv._log_name = f"docker_exec console '{vm.name}'" + + async def _on_naws(srv, columns, rows): + 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 run(srv, network_reader, network_writer): + """Catch and log any exception that kills the client session.""" + try: + await super().run(network_reader, network_writer) + except Exception as exc: + log.warning(f"{srv._log_name}: client session terminated: {exc}", exc_info=True) + + 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": ["sh", "-c", f"while true; do {command}; done"], + }, + ) + srv._exec_id = result["Id"] + log.info(f"{srv._log_name}: exec created ({srv._exec_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] + log.info(f"{srv._log_name}: hijacked start -> {status_line.decode(errors='ignore')}") + 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()) + log.info(f"{srv._log_name}: broadcast task started, upstream wired, ready") + + async def client_connected_hook(srv): + await super().client_connected_hook() + log.info(f"{srv._log_name}: client connected, lazy_started={srv._started}") + async with srv._lock: + if not srv._started: + try: + await srv._create_exec() + except Exception as exc: + log.warning(f"{srv._log_name}: failed to create exec: {exc}", exc_info=True) + raise + 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 as exc: + log.warning(f"{srv._log_name}: Ctrl-L write failed: {exc}") + log.info(f"{srv._log_name}: client_connected_hook done") + + 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}") From 2f36471a55380eae0f08205fcb7d0aa5c22bee4f Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 12 Aug 2026 22:58:46 +0800 Subject: [PATCH 05/15] fix: host-side permission fix + SKIP_INIT volume persistence docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the container-side _fix_permissions for vendor NOS containers with a host-side pass that walks the node's project directories directly (they are the Docker bind-mount sources): records mode:uid:gid into .gns3_perms and chowns to the GNS3 user. No docker exec, no container restart — the base implementation restarts an exited container just to chown, and after the restart the mount --bind bridge is gone so it would fix the overlay copy instead of the host files. The pass runs at start (after _setup_skip_init_volumes seeds and bridges the volumes) so the controller can read project files while the node runs, and again at stop for files written during runtime. Update docker-exec-console.md: VendorDockerVM architecture, hook points, class-selection factory, volume-persistence lifecycle, and new troubleshooting entries. --- docs/features/docker-exec-console.md | 154 ++++++++++++++++-- gns3server/compute/docker/vendor_docker_vm.py | 76 +++++++++ 2 files changed, 215 insertions(+), 15 deletions(-) diff --git a/docs/features/docker-exec-console.md b/docs/features/docker-exec-console.md index a6d6cb839..549bbbda4 100644 --- a/docs/features/docker-exec-console.md +++ b/docs/features/docker-exec-console.md @@ -43,12 +43,47 @@ they stay host-side configuration. | `GNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3` | Rename the injected interfaces in adapter order instead of the default `eth{N}`. SR Linux expects `mgmt0` + `e1-N`; without this it does not recognise its datapath. | | `GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli` | Command run by the `docker_exec` console inside the container. | +## Architecture: `VendorDockerVM` subclass + +All vendor-specific logic lives in a `VendorDockerVM(DockerVM)` subclass in +`gns3server/compute/docker/vendor_docker_vm.py` — `docker_vm.py` itself stays +on its baseline behaviour and is never touched by this feature. + +`DockerVM` exposes four small extension hooks (pure refactorings, zero +behaviour change for existing nodes): + +| Hook | Baseline behaviour | `VendorDockerVM` override | +|------|--------------------|---------------------------| +| `_prepare_init_and_interface_env(params)` | prepend `/gns3/init.sh`, set `GNS3_MAX_ETHERNET=eth{N-1}` | conditional init.sh (`GNS3_SKIP_INIT`), `GNS3_MAX_ETHERNET` follows the interface rename | +| `_start_console_server()` | telnet/ssh/http console dispatch | adds the `docker_exec` branch | +| `_get_container_ifname(adapter_number)` | `eth{N}` | `GNS3_INTERFACE_NAMES` lookup, fallback `eth{N}` | +| `_cleanup_console_resources()` | no-op | closes the docker-exec pty socket before restart/stop | + +### Class selection + +The Docker manager picks the class per node in `Docker.create_node()` +(`gns3server/compute/docker/__init__.py`): + +```python +def _select_node_class(self, **kwargs): + if kwargs.get("console_type") == "docker_exec": + return VendorDockerVM + return DockerVM +``` + +`console_type == "docker_exec"` is the **only** trigger — every other console +type (telnet, vnc, ssh, http, …) keeps using the unmodified `DockerVM`. All +vendor features are opt-in: without the `GNS3_*` environment variables a +`VendorDockerVM` instance behaves identically to `DockerVM` (init.sh still +runs, interfaces stay `eth{N}`, the exec command defaults to `/bin/sh`), so a +regular container can use `docker_exec` too. + ## The `docker_exec` console type Setting `console_type: "docker_exec"` makes the node's primary console port run `_start_docker_exec_console()` instead of the attach-to-PID-1 path. -### Architecture +### Console architecture ```mermaid graph LR @@ -67,7 +102,8 @@ behaves. ### Implementation -**File**: `gns3server/compute/docker/docker_vm.py` — `_start_docker_exec_console()` +**File**: `gns3server/compute/docker/vendor_docker_vm.py` — +`_start_docker_exec_console()` A small subclass `_LazyExecTelnetServer(AsyncioTelnetServer)` implements the console. Key points: @@ -88,7 +124,13 @@ console. Key points: CLI"* otherwise), and `Env: ["TERM=xterm"]` (the TUI library needs a recognised terminal). -3. **Hijacked raw-HTTP start.** The exec is started with +3. **while-true wrapper.** The command is wrapped in + `sh -c "while true; do ; done"` so that when the CLI exits (user types + `quit`, or the NOS's own idle timeout logs the session out), a fresh CLI + instance starts in the same pty instead of killing the shared console + session. + +4. **Hijacked raw-HTTP start.** The exec is started with `POST exec/{eid}/start` sent as a raw HTTP upgrade over the Docker unix socket (`asyncio.open_unix_connection`), the same approach docker-py uses. This is required because aiohttp's websocket client (`ws_connect`) is @@ -96,11 +138,11 @@ console. Key points: upgrade succeeds (101). With `Tty:true` the response body is a raw, non-multiplexed bidirectional pty byte stream — no frame demux needed. -4. **NAWS → exec resize.** The telnet server runs with `naws=True`; the +5. **NAWS → exec resize.** The telnet server runs with `naws=True`; the `window_size_changed_callback` calls `POST exec/{eid}/resize?h=&w=` so the TUI lays out for the xterm.js window size. -5. **Binary passthrough + redraw.** `binary=True` so TUI escape sequences reach +6. **Binary passthrough + redraw.** `binary=True` so TUI escape sequences reach xterm.js intact; `echo=False` (the pty echoes). On every client (re)connect a `Ctrl-L` (`\x0c`) is sent to the pty so a TUI that already drew its screen for a previous client redraws for the new one (otherwise a @@ -147,9 +189,62 @@ The exec-API approach fixes all of these: a real pty (`Tty:true`), a real size ### Persistent state -For SR Linux, persist `/etc/opt/srlinux` (config / AAA users / TLS certs) by -adding it to the node's `extra_volumes`. `/var/opt/srlinux` does **not** exist -on current SR Linux images; `/var/log/srlinux` holds logs (optional). +For SR Linux, persist `/etc/opt/srlinux` (config / AAA users / TLS certs) and +`/var/log/srlinux` (logs, optional) by adding them to the node's +`extra_volumes`. The image also declares its own `VOLUME` directories +(e.g. `/opt/srlinux/appmgr`), which GNS3 persists automatically. + +## Volume persistence with `GNS3_SKIP_INIT` + +This is the one place where skipping init.sh changes behaviour beyond boot: +`/gns3/init.sh` normally performs the volume-persistence bridge, and without it +**nothing writes through to the host** — the container writes to its overlay +filesystem and the data is lost on stop. + +The bridge (see init.sh lines 35–52) has two parts: + +``` +host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always mounted) + │ init.sh: mount --bind + ▼ + /etc/opt/srlinux (where the NOS writes) +``` + +`VendorDockerVM` replicates this for SKIP_INIT containers: + +1. **`_setup_skip_init_volumes()`** — runs once per start, right after the + container is up (`VendorDockerVM.start()`). For each persistent volume it + `docker exec`s a busybox script that: + - seeds the host directory with the container's original files on first + start (`cp -a` + `.gns3_perms` marker), exactly like init.sh; + - `mount --bind /gns3volumes ` to bridge persistent storage + back to the in-container path — on subsequent starts the persisted data + replaces the fresh overlay content; + - restores the permissions recorded in `.gns3_perms` at the previous stop + (best-effort). + +2. **Host-side `_fix_permissions()` override** — `DockerVM._fix_permissions` + is container-side (busybox via `docker exec`) and restarts an exited + container just to chown; after a restart the `mount --bind` bridge is gone, + so it would fix the overlay copy and not the host files. The override + instead walks the host-side directories under the node's project directory + directly (they *are* the Docker bind-mount sources), records + `mode:uid:gid:path` into `.gns3_perms` and chowns to the GNS3 user — + no running container required, no restart. It runs both at start (so the + controller can read project files while the node runs) and at stop. + +> Rootful-Docker assumption: the `.gns3_perms` uid/gid values are recorded from +> the host's view. With rootful Docker (no userns remap) in-container and host +> ids coincide, so restore semantics are identical to init.sh's. This would +> need revisiting for userns-remapped daemons. + +### Lifecycle summary + +| Phase | Normal Docker node | `VendorDockerVM` + `GNS3_SKIP_INIT` | +|-------|--------------------|--------------------------------------| +| start | init.sh seeds + bind-mounts + restores perms (in-container, before the app starts) | `docker exec` after start: seed + bind-mount + restore perms; then host-side chown | +| stop | container-side `_fix_permissions` (restarts an exited container) | host-side `_fix_permissions` (no container needed) | +| volume config | identical `_mount_binds` (host → `/gns3volumes`) | identical | ## Troubleshooting @@ -180,23 +275,51 @@ on current SR Linux images; `/var/log/srlinux` holds logs (optional). network-instance before ping works. This is SR Linux behaviour, not a GNS3 issue. +**7. "Session has been idle, will logout in 300 seconds" → Connection closed** +- SR Linux's own CLI idle timeout. The while-true wrapper restarts the CLI + automatically, but to keep a permanent session disable the timeout in the + CLI: `enter candidate` → `/system cli idle-timeout disable` → `commit now`. + +**8. Controller logs `Permission denied` reading files under the node's + project directory while the node runs** +- Root-written files inside a persistent volume. The host-side + `_fix_permissions` pass runs at start (fixes the seeded files) and at stop; + files created by the container *during* runtime become readable after the + next stop. + +**9. Persistent volume empty on the host after `save` + stop** +- Ensure `GNS3_SKIP_INIT=1` is set (so the host-side bridge path is taken) and + the volume path is in `extra_volumes`; check the compute log for + `Volume '' bound to persistent storage`. + ## Limitations 1. **Shared session (broadcast).** All console clients share one CLI session and can see each other's input — identical to GNS3's existing primary console model. There is no per-client independent session. 2. **`reset_console` not wired.** The console-reset action only handles - `telnet`/`ssh`; it is a no-op for `docker_exec` (non-blocking; reconnect - works fine). + `telnet`/`ssh`; it is a no-op for `docker_exec` (non-blocking; reconnect + works fine). 3. **Prototype knobs.** `GNS3_SKIP_INIT` / `GNS3_INTERFACE_NAMES` / - `GNS3_CONSOLE_CMD` are environment-driven; they are not yet first-class node - schema fields and are not declared in the appliance (`gns3a`) schema. + `GNS3_CONSOLE_CMD` are environment-driven; they are not yet first-class node + schema fields and are not declared in the appliance (`gns3a`) schema. +4. **Rootful-Docker assumption** for the host-side `.gns3_perms` recording + (see the volume-persistence section). +5. **Post-boot volume bridge.** The bind-mount bridge is established after the + vendor entrypoint has started (init.sh would do it before). A NOS that + strictly requires its persisted files at its very first read may need a + different boot arrangement. ## References -- `gns3server/compute/docker/docker_vm.py` — `_start_docker_exec_console`, - `_LazyExecTelnetServer`, `_add_ubridge_connection` (interface rename), - `create()` (env parsing, skip-init, `GNS3_MAX_ETHERNET`). +- `gns3server/compute/docker/vendor_docker_vm.py` — `VendorDockerVM`: + `_start_docker_exec_console`, `_LazyExecTelnetServer`, + `_setup_skip_init_volumes`, host-side `_fix_permissions`, `start()`. +- `gns3server/compute/docker/docker_vm.py` — `DockerVM` extension hooks + (`_prepare_init_and_interface_env`, `_start_console_server`, + `_get_container_ifname`, `_cleanup_console_resources`). +- `gns3server/compute/docker/__init__.py` — `Docker._select_node_class` / + `create_node` factory. - `gns3server/compute/base_node.py` — console WebSocket guard. - `gns3server/schemas/common.py` — `ConsoleType.docker_exec`. - containerlab `nodes/srl/srl.go` — reference for SR Linux launch command and @@ -206,4 +329,5 @@ on current SR Linux images; `/var/log/srlinux` holds logs (optional). | Version | Date | Changes | |---------|------|---------| +| 1.1 | 2026-08-12 | Refactor: vendor logic extracted from `DockerVM` into `VendorDockerVM` subclass with 4 hook points + class-selection factory. Add SKIP_INIT volume persistence (`_setup_skip_init_volumes` + host-side `_fix_permissions`) and lifecycle comparison. Add troubleshooting entries for idle timeout and permission-denied files. | | 1.0 | 2026-08-12 | Initial documentation of the `docker_exec` console and vendor NOS knobs. | diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index cd03fa0fe..a7e5674e8 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -29,6 +29,8 @@ container behaves identically to DockerVM. import asyncio import json import logging +import os +import stat from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer from gns3server.compute.docker.docker_vm import DockerVM @@ -119,6 +121,80 @@ class VendorDockerVM(DockerVM): await super().start() if self.status == "started" and not self._gns3_init: await self._setup_skip_init_volumes() + # Fix host-side ownership of the seeded volume right away so the + # controller can read project files while the node runs. Reset the + # "fixed" flag afterwards: files written by the container during + # runtime still need the stop-time pass. + await self._fix_permissions() + self._permissions_fixed = False + + async def _fix_permissions(self): + """ + Host-side override of DockerVM._fix_permissions for SKIP_INIT + containers. The persistent volumes are Docker bind mounts of + directories under the node's project directory, so ownership is fixed + directly on the host — no docker exec, no container restart required + (the base implementation restarts an exited container just to chown, + which is wasteful for vendor NOS images). + + Two passes per volume, mirroring the base/busybox behaviour: + + 1. record each entry's container-visible mode/uid/gid into + `.gns3_perms` (same `mode:uid:gid:path` format init.sh consumes, + paths are in-container absolute so the restore inside the + container resolves them); + 2. chmod u+rX + chown to the host user so the GNS3 process can read + and delete files from the project directory. + """ + uid, gid = os.getuid(), os.getgid() + for volume in self._volumes: + path = os.path.join(self.working_dir, os.path.relpath(volume, "/")) + if not os.path.isdir(path): + continue + + def onerror(exc): + log.debug("Could not walk '%s' for container '%s': %s", exc.filename, self._name, exc) + + # 1. record container-visible permissions for restore at next start + try: + with open(os.path.join(path, ".gns3_perms"), "w") as perms_file: + for root, dirs, files in os.walk(path, onerror=onerror): + for entry in dirs + files: + entry_path = os.path.join(root, entry) + try: + st = os.lstat(entry_path) + except OSError: + continue + container_path = os.path.join(volume, os.path.relpath(entry_path, path)) + perms_file.write( + f"{stat.S_IMODE(st.st_mode):o}:{st.st_uid}:{st.st_gid}:{container_path}\n" + ) + except OSError as e: + log.warning( + "Could not record permissions for '%s' on container '%s': %s", path, self._name, e + ) + continue + + # 2. chmod u+rX + chown to the host user + for root, dirs, files in os.walk(path, onerror=onerror): + for entry in dirs + files: + entry_path = os.path.join(root, entry) + try: + st = os.lstat(entry_path) + is_link = stat.S_ISLNK(st.st_mode) + if not is_link: + mode = stat.S_IMODE(st.st_mode) + new_mode = mode | 0o400 # u+r + if stat.S_ISDIR(st.st_mode) or (mode & 0o111): # u+X + new_mode |= 0o100 + os.chmod(entry_path, new_mode) + os.lchown(entry_path, uid, gid) + except OSError as e: + log.debug( + "Could not fix permissions on '%s' for container '%s': %s", + entry_path, self._name, e, + ) + self._permissions_fixed = True async def _setup_skip_init_volumes(self): """ From 3455da7da367943bde767a177b19a38ee3f0adaa Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 12 Aug 2026 23:06:34 +0800 Subject: [PATCH 06/15] fix: run _fix_permissions container-side on /gns3volumes mount targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host-side pass could not work for unprivileged GNS3 processes: the .gns3_perms marker is created root-owned by the container-side touch, and chowning root-owned files from the host requires root. Rewrite VendorDockerVM._fix_permissions to run the busybox record/chmod/chown script inside the container (as root) on the /gns3volumes bind-mount targets — they exist for the container's whole lifetime and do not depend on the mount --bind bridge, so a container restart can no longer make the fix hit the overlay copy. A stopped/exited container is skipped (logged) instead of restarted; the next start's pass fixes ownership. --- docs/features/docker-exec-console.md | 46 +++---- gns3server/compute/docker/vendor_docker_vm.py | 112 ++++++++---------- 2 files changed, 78 insertions(+), 80 deletions(-) diff --git a/docs/features/docker-exec-console.md b/docs/features/docker-exec-console.md index 549bbbda4..d61bd2097 100644 --- a/docs/features/docker-exec-console.md +++ b/docs/features/docker-exec-console.md @@ -223,27 +223,30 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m - restores the permissions recorded in `.gns3_perms` at the previous stop (best-effort). -2. **Host-side `_fix_permissions()` override** — `DockerVM._fix_permissions` - is container-side (busybox via `docker exec`) and restarts an exited - container just to chown; after a restart the `mount --bind` bridge is gone, - so it would fix the overlay copy and not the host files. The override - instead walks the host-side directories under the node's project directory - directly (they *are* the Docker bind-mount sources), records - `mode:uid:gid:path` into `.gns3_perms` and chowns to the GNS3 user — - no running container required, no restart. It runs both at start (so the - controller can read project files while the node runs) and at stop. +2. **Container-side `_fix_permissions()` override targeting `/gns3volumes`** + — `DockerVM._fix_permissions` operates on the in-container paths + (`/etc/opt/srlinux`, …), which only resolve to persistent storage while + the `mount --bind` bridge is up; after a container restart the bridge is + gone and it would chown the overlay copy instead of the host files. It + also restarts an exited container just to chown. The override instead + runs the same busybox record/chmod/chown script **inside the container + (as root) on the `/gns3volumes` paths** — the Docker bind-mount + targets, which exist for the whole container lifetime and need no bridge. + A stopped/exited container is **not** restarted: the pass is skipped and + the next start fixes ownership. It runs at start (so the controller can + read project files while the node runs) and at stop (for files written + during runtime). -> Rootful-Docker assumption: the `.gns3_perms` uid/gid values are recorded from -> the host's view. With rootful Docker (no userns remap) in-container and host -> ids coincide, so restore semantics are identical to init.sh's. This would -> need revisiting for userns-remapped daemons. +> The fix must run container-side: files written by the container are +> host-side root-owned, and an unprivileged GNS3 process cannot chown them +> from the host. Container-side root (with GNS3's `UsernsMode: host`) can. ### Lifecycle summary | Phase | Normal Docker node | `VendorDockerVM` + `GNS3_SKIP_INIT` | |-------|--------------------|--------------------------------------| -| start | init.sh seeds + bind-mounts + restores perms (in-container, before the app starts) | `docker exec` after start: seed + bind-mount + restore perms; then host-side chown | -| stop | container-side `_fix_permissions` (restarts an exited container) | host-side `_fix_permissions` (no container needed) | +| start | init.sh seeds + bind-mounts + restores perms (in-container, before the app starts) | `docker exec` after start: seed + bind-mount + restore perms; then container-side chown on `/gns3volumes` | +| stop | container-side `_fix_permissions` on in-container paths (restarts an exited container) | container-side `_fix_permissions` on `/gns3volumes` paths (skips dead containers, no restart) | | volume config | identical `_mount_binds` (host → `/gns3volumes`) | identical | ## Troubleshooting @@ -282,7 +285,7 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m **8. Controller logs `Permission denied` reading files under the node's project directory while the node runs** -- Root-written files inside a persistent volume. The host-side +- Root-written files inside a persistent volume. The container-side `_fix_permissions` pass runs at start (fixes the seeded files) and at stop; files created by the container *during* runtime become readable after the next stop. @@ -303,8 +306,9 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m 3. **Prototype knobs.** `GNS3_SKIP_INIT` / `GNS3_INTERFACE_NAMES` / `GNS3_CONSOLE_CMD` are environment-driven; they are not yet first-class node schema fields and are not declared in the appliance (`gns3a`) schema. -4. **Rootful-Docker assumption** for the host-side `.gns3_perms` recording - (see the volume-persistence section). +4. **Rootful-Docker assumption** (`UsernsMode: host`, set for all GNS3 + Docker nodes) so the container-side chown acts on the host files' real + uid/gid (see the volume-persistence section). 5. **Post-boot volume bridge.** The bind-mount bridge is established after the vendor entrypoint has started (init.sh would do it before). A NOS that strictly requires its persisted files at its very first read may need a @@ -314,7 +318,8 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m - `gns3server/compute/docker/vendor_docker_vm.py` — `VendorDockerVM`: `_start_docker_exec_console`, `_LazyExecTelnetServer`, - `_setup_skip_init_volumes`, host-side `_fix_permissions`, `start()`. + `_setup_skip_init_volumes`, container-side `_fix_permissions` on + `/gns3volumes`, `start()`. - `gns3server/compute/docker/docker_vm.py` — `DockerVM` extension hooks (`_prepare_init_and_interface_env`, `_start_console_server`, `_get_container_ifname`, `_cleanup_console_resources`). @@ -329,5 +334,6 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m | Version | Date | Changes | |---------|------|---------| -| 1.1 | 2026-08-12 | Refactor: vendor logic extracted from `DockerVM` into `VendorDockerVM` subclass with 4 hook points + class-selection factory. Add SKIP_INIT volume persistence (`_setup_skip_init_volumes` + host-side `_fix_permissions`) and lifecycle comparison. Add troubleshooting entries for idle timeout and permission-denied files. | +| 1.2 | 2026-08-12 | `_fix_permissions` rewritten: container-side (as root) on the `/gns3volumes` bind-mount targets instead of host-side — host-side chown cannot touch root-owned files when GNS3 is unprivileged. Dead containers are skipped instead of restarted. | +| 1.1 | 2026-08-12 | Refactor: vendor logic extracted from `DockerVM` into `VendorDockerVM` subclass with 4 hook points + class-selection factory. Add SKIP_INIT volume persistence (`_setup_skip_init_volumes` + `_fix_permissions`) and lifecycle comparison. Add troubleshooting entries for idle timeout and permission-denied files. | | 1.0 | 2026-08-12 | Initial documentation of the `docker_exec` console and vendor NOS knobs. | diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index a7e5674e8..b3a9009ce 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -30,11 +30,10 @@ import asyncio import json import logging import os -import stat from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer from gns3server.compute.docker.docker_vm import DockerVM -from gns3server.compute.docker.docker_error import DockerError +from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error log = logging.getLogger(__name__) @@ -130,71 +129,64 @@ class VendorDockerVM(DockerVM): async def _fix_permissions(self): """ - Host-side override of DockerVM._fix_permissions for SKIP_INIT - containers. The persistent volumes are Docker bind mounts of - directories under the node's project directory, so ownership is fixed - directly on the host — no docker exec, no container restart required - (the base implementation restarts an exited container just to chown, - which is wasteful for vendor NOS images). + Container-side override of DockerVM._fix_permissions for vendor NOS + containers. It targets the Docker bind-mount paths + (`/gns3volumes`) directly instead of the in-container paths: + the in-container paths only resolve to persistent storage while the + `mount --bind` bridge from _setup_skip_init_volumes is up, and after a + container restart the bridge is gone — the base implementation would + then chown the overlay copy instead of the host files. - Two passes per volume, mirroring the base/busybox behaviour: + The busybox script runs inside the container as root (a host-side + GNS3 process may be unprivileged and cannot chown root-owned files). - 1. record each entry's container-visible mode/uid/gid into - `.gns3_perms` (same `mode:uid:gid:path` format init.sh consumes, - paths are in-container absolute so the restore inside the - container resolves them); - 2. chmod u+rX + chown to the host user so the GNS3 process can read - and delete files from the project directory. + Unlike the base implementation, a stopped/exited container is NOT + restarted just to fix permissions (vendor NOS images are heavy to + boot): the pass is skipped and the next start fixes ownership. """ + try: + state = await self._get_container_state() + except DockerHttp404Error: + log.warning("Container '%s' does not exist, skipping permission fix", self._name) + return + if state == "stopped" or state == "exited": + log.info( + "Container '%s' is %s, skipping permission fix (next start will fix)", + self._name, state, + ) + return + uid, gid = os.getuid(), os.getgid() for volume in self._volumes: - path = os.path.join(self.working_dir, os.path.relpath(volume, "/")) - if not os.path.isdir(path): - continue - - def onerror(exc): - log.debug("Could not walk '%s' for container '%s': %s", exc.filename, self._name, exc) - - # 1. record container-visible permissions for restore at next start + target = f"/gns3volumes{volume}" + log.debug("Docker container '%s' fix ownership on %s", self._name, target) try: - with open(os.path.join(path, ".gns3_perms"), "w") as perms_file: - for root, dirs, files in os.walk(path, onerror=onerror): - for entry in dirs + files: - entry_path = os.path.join(root, entry) - try: - st = os.lstat(entry_path) - except OSError: - continue - container_path = os.path.join(volume, os.path.relpath(entry_path, path)) - perms_file.write( - f"{stat.S_IMODE(st.st_mode):o}:{st.st_uid}:{st.st_gid}:{container_path}\n" - ) - except OSError as e: - log.warning( - "Could not record permissions for '%s' on container '%s': %s", path, self._name, e + process = await asyncio.subprocess.create_subprocess_exec( + "docker", + "exec", + self._cid, + "/gns3/bin/busybox", + "sh", + "-c", + "(" + f'/gns3/bin/busybox find "{target}" -depth -print0' + f" | /gns3/bin/busybox xargs -0 /gns3/bin/busybox stat -c '%a:%u:%g:%n' > \"{target}/.gns3_perms\"" + ")" + f' && /gns3/bin/busybox chmod -R u+rX "{target}"' + f' && /gns3/bin/busybox chown {uid}:{gid} -R "{target}"', + stderr=asyncio.subprocess.PIPE, ) - continue - - # 2. chmod u+rX + chown to the host user - for root, dirs, files in os.walk(path, onerror=onerror): - for entry in dirs + files: - entry_path = os.path.join(root, entry) - try: - st = os.lstat(entry_path) - is_link = stat.S_ISLNK(st.st_mode) - if not is_link: - mode = stat.S_IMODE(st.st_mode) - new_mode = mode | 0o400 # u+r - if stat.S_ISDIR(st.st_mode) or (mode & 0o111): # u+X - new_mode |= 0o100 - os.chmod(entry_path, new_mode) - os.lchown(entry_path, uid, gid) - except OSError as e: - log.debug( - "Could not fix permissions on '%s' for container '%s': %s", - entry_path, self._name, e, - ) - self._permissions_fixed = True + except OSError as e: + raise DockerError(f"Could not fix permissions for {volume}: {e}") + await process.wait() + if process.returncode != 0: + stderr = (await process.stderr.read()).decode(errors="replace").strip() + log.error( + "Failed to fix permissions on '%s' for container '%s': %s", + volume, self._name, stderr or f"exit code {process.returncode}", + ) + else: + self._permissions_fixed = True async def _setup_skip_init_volumes(self): """ From 6f852c83fd6951ff516591cae1c4fb153de523cd Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 12 Aug 2026 23:15:55 +0800 Subject: [PATCH 07/15] docs: explain mid-run Permission denied warning for boot-written NOS files Document the aaamgr_local_user.json case: SR Linux's aaamgr daemon rewrites the file during boot as the image's srlinux user (uid 1002) after the start-time permission pass, leaving it unreadable until the stop-time pass. Trace the warning to the file-browser API chain (Show in file manager -> list_node_files -> magic.from_file) and note the impact is limited to the file_type field. --- docs/features/docker-exec-console.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/features/docker-exec-console.md b/docs/features/docker-exec-console.md index d61bd2097..4c1917eee 100644 --- a/docs/features/docker-exec-console.md +++ b/docs/features/docker-exec-console.md @@ -289,6 +289,19 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m `_fix_permissions` pass runs at start (fixes the seeded files) and at stop; files created by the container *during* runtime become readable after the next stop. +- Concrete example: SR Linux's `aaamgr` daemon rewrites + `etc/opt/srlinux/aaamgr_local_user.json` during boot, **after** the + start-time pass, as the image's `srlinux` user (uid 1002, mode 700) — so + the host-side file stays `1002:1002` until the stop-time pass chowns it. +- The log line comes from the file-browser API chain: Web UI *Show in file + manager* → `GET /v3/projects/{pid}/nodes/{nid}/files` + (`controller/nodes.py:538`) → `project.list_node_files` + (`compute/project.py:510`), where `magic.from_file()` cannot read the + file and the `file_type` field is left empty for that entry. The MCP + `list_node_files` tool uses the same code path. Size/modified-at fields + and everything else keep working; only the type sniff and one warning + line are affected — same behaviour as any regular Docker node writing + root-owned files at runtime. **9. Persistent volume empty on the host after `save` + stop** - Ensure `GNS3_SKIP_INIT=1` is set (so the host-side bridge path is taken) and From 486c5cc1cbe2f42aa0fdd5659b4222dece4ff373 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 12 Aug 2026 23:26:14 +0800 Subject: [PATCH 08/15] docs: runtime ownership safety and boot-ordering caveat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document why host-user ownership of volume files during runtime is harmless for SR Linux (root processes, self-healing daemons like aaamgr rewriting its managed files, ACL-based access) and the deviation from the standard init.sh model, with the escape hatch of dropping the start-time fix pass for strict-ownership NOS images. Also document the boot-ordering caveat: the volume bridge comes up after the NOS boots, so early boot reads overlay defaults — verify the save/stop/start closed loop, and add troubleshooting entry #10 for config present on the host but not applied after restart. --- docs/features/docker-exec-console.md | 47 +++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/docs/features/docker-exec-console.md b/docs/features/docker-exec-console.md index 4c1917eee..3c7058199 100644 --- a/docs/features/docker-exec-console.md +++ b/docs/features/docker-exec-console.md @@ -249,6 +249,42 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m | stop | container-side `_fix_permissions` on in-container paths (restarts an exited container) | container-side `_fix_permissions` on `/gns3volumes` paths (skips dead containers, no restart) | | volume config | identical `_mount_binds` (host → `/gns3volumes`) | identical | +### Runtime ownership safety + +The start-time fix pass chowns the volume files to the host user **while the +container is running** — a deliberate deviation from the standard model, where +init.sh restores container-native ownership at start and the container never +sees host-owned files during runtime. Verified harmless for SR Linux: + +1. **Most processes run as root** (`sr_linux`, appmgr) — root ignores file + ownership entirely. +2. **Self-healing daemons.** SR Linux's `aaamgr` rewrites its managed files + with its own ownership at boot: after the start-time pass chowned + `etc/opt/srlinux/aaamgr_local_user.json` to the host user, the daemon + re-created it as `srlinux:srlinux` (uid 1002, mode 700) within seconds. +3. **ACL-based access.** The directory carries a default ACL + (`default:group:srlinux:rwx`, `default:other::rwx`), so named group ACL + entries grant access independently of the owner uid; the observed file ACL + (`group:srlinux:rwx`, owner `srlinux`) survives chown. + +Caveat: a NOS that strictly validates ownership of its files (e.g. "SSH keys +must be root:root 600 or refuse to start") would not tolerate this. If that +ever matters, drop the start-time pass and keep only the stop-time one +(standard behaviour — the trade-off is mid-run `Permission denied` in the +file browser, identical to regular Docker nodes). + +### Boot-ordering caveat + +The volume bridge (`mount --bind`) is established **after** the vendor +entrypoint has started (there is no init.sh to do it before), so the NOS's +early boot reads the overlay copy of the volume paths — default image +content, not the persisted data. Whether the persisted config takes effect +depends on the NOS re-reading those files after the bridge is up (SR Linux's +daemons do re-read/write their managed files during boot, as observed). +Always verify the closed loop when adopting a new image: `save` a config → +stop the node → start it → confirm the config is actually applied, not just +present on the host. + ## Troubleshooting **1. Console shows only boot logs, no CLI** @@ -308,6 +344,14 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m the volume path is in `extra_volumes`; check the compute log for `Volume '' bound to persistent storage`. +**10. Persisted config present on the host but not applied after restart** +- The volume bridge is established after the NOS has booted (see + *Boot-ordering caveat*); the NOS may have already loaded the overlay's + default config into memory. Verify with a visible change (hostname, + interface description): `save` → stop → start → check the change took + effect. If it does not, the image needs the bridge earlier (a + vendor-specific entrypoint wrapper, not covered by this prototype). + ## Limitations 1. **Shared session (broadcast).** All console clients share one CLI session @@ -325,7 +369,7 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m 5. **Post-boot volume bridge.** The bind-mount bridge is established after the vendor entrypoint has started (init.sh would do it before). A NOS that strictly requires its persisted files at its very first read may need a - different boot arrangement. + different boot arrangement (see *Boot-ordering caveat*). ## References @@ -347,6 +391,7 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m | Version | Date | Changes | |---------|------|---------| +| 1.3 | 2026-08-12 | Document runtime ownership safety (root processes, self-healing daemons, ACL evidence for SR Linux), the boot-ordering caveat (bridge after boot → verify save/stop/start closed loop), and troubleshooting #10. | | 1.2 | 2026-08-12 | `_fix_permissions` rewritten: container-side (as root) on the `/gns3volumes` bind-mount targets instead of host-side — host-side chown cannot touch root-owned files when GNS3 is unprivileged. Dead containers are skipped instead of restarted. | | 1.1 | 2026-08-12 | Refactor: vendor logic extracted from `DockerVM` into `VendorDockerVM` subclass with 4 hook points + class-selection factory. Add SKIP_INIT volume persistence (`_setup_skip_init_volumes` + `_fix_permissions`) and lifecycle comparison. Add troubleshooting entries for idle timeout and permission-denied files. | | 1.0 | 2026-08-12 | Initial documentation of the `docker_exec` console and vendor NOS knobs. | From 46e863b97500cbc44adc2470ff393a701efe6dcd Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 12 Aug 2026 23:32:08 +0800 Subject: [PATCH 09/15] vendor: skip /etc/network in volume bridge and permission passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GNS3_SKIP_INIT containers never run init.sh, so /etc/network (GNS3's own network config consumed by init.sh's ifup) has no consumer — the NOS manages its own interfaces. VendorDockerVM._persistent_volumes() filters it out for both _setup_skip_init_volumes and _fix_permissions, saving one docker exec per pass. The shared _mount_binds is untouched, and without GNS3_SKIP_INIT the full volume list is returned so behaviour matches the base class. --- docs/features/docker-exec-console.md | 7 +++++++ gns3server/compute/docker/vendor_docker_vm.py | 17 +++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/features/docker-exec-console.md b/docs/features/docker-exec-console.md index 3c7058199..b61a67188 100644 --- a/docs/features/docker-exec-console.md +++ b/docs/features/docker-exec-console.md @@ -241,6 +241,13 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m > host-side root-owned, and an unprivileged GNS3 process cannot chown them > from the host. Container-side root (with GNS3's `UsernsMode: host`) can. +With `GNS3_SKIP_INIT`, `/etc/network` is skipped by both vendor passes +(`VendorDockerVM._persistent_volumes()`): it holds GNS3's own network config +for init.sh's `ifup`, which never runs for SKIP_INIT containers — the NOS +manages its own interfaces. The Docker mount itself is left alone (shared +`_mount_binds`, hardcoded at `docker_vm.py` `_mount_binds()`); only the +vendor-side bridge/fix passes skip it. + ### Lifecycle summary | Phase | Normal Docker node | `VendorDockerVM` + `GNS3_SKIP_INIT` | diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index b3a9009ce..1c80eac7c 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -95,6 +95,19 @@ class VendorDockerVM(DockerVM): last_ifname = f"eth{self.adapters - 1}" params["Env"].append(f"GNS3_MAX_ETHERNET={last_ifname}") + def _persistent_volumes(self): + """ + Volumes relevant to vendor persistence. With GNS3_SKIP_INIT, drop + /etc/network — GNS3's own network config for init.sh's ifup, unused + when init.sh is skipped (the NOS manages its own interfaces). The + Docker mount itself is left alone (shared _mount_binds); only the + vendor-side bridge/fix passes skip it. Without SKIP_INIT the full + list is returned so behaviour matches the base class. + """ + if self._gns3_init: + return self._volumes + return [v for v in self._volumes if v != "/etc/network"] + def _get_container_ifname(self, adapter_number): """ Override: honour GNS3_INTERFACE_NAMES (e.g. mgmt0, e1-1) in adapter @@ -157,7 +170,7 @@ class VendorDockerVM(DockerVM): return uid, gid = os.getuid(), os.getgid() - for volume in self._volumes: + for volume in self._persistent_volumes(): target = f"/gns3volumes{volume}" log.debug("Docker container '%s' fix ownership on %s", self._name, target) try: @@ -199,7 +212,7 @@ class VendorDockerVM(DockerVM): Permission-changes recorded by _fix_permissions at the previous stop are restored (best-effort). """ - for volume in self._volumes: + for volume in self._persistent_volumes(): vol_target = f"/gns3volumes{volume}" # fmt: off script = ( From f5554d816a002fa28958a21448b3ed0a2ce83dbd Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 12 Aug 2026 23:41:45 +0800 Subject: [PATCH 10/15] vendor: drop the hardcoded /etc/network mount for SKIP_INIT containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Override _mount_binds in VendorDockerVM: for GNS3_SKIP_INIT containers the /etc/network volume (GNS3's own network config consumed by init.sh's ifup) is dead weight — init.sh never runs and the NOS manages its own interfaces. The override removes the bind, filters /etc/network out of self._volumes (keeping GNS3_VOLUMES and the bridge/fix passes consistent) and deletes the host-side skeleton directory created by the base class. Without GNS3_SKIP_INIT the mount is kept, matching base behaviour. _persistent_volumes() is removed — the mount override is now the single filter point. --- docs/features/docker-exec-console.md | 14 ++++--- gns3server/compute/docker/vendor_docker_vm.py | 39 ++++++++++++------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/docs/features/docker-exec-console.md b/docs/features/docker-exec-console.md index b61a67188..dd741ff87 100644 --- a/docs/features/docker-exec-console.md +++ b/docs/features/docker-exec-console.md @@ -241,12 +241,14 @@ host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always m > host-side root-owned, and an unprivileged GNS3 process cannot chown them > from the host. Container-side root (with GNS3's `UsernsMode: host`) can. -With `GNS3_SKIP_INIT`, `/etc/network` is skipped by both vendor passes -(`VendorDockerVM._persistent_volumes()`): it holds GNS3's own network config -for init.sh's `ifup`, which never runs for SKIP_INIT containers — the NOS -manages its own interfaces. The Docker mount itself is left alone (shared -`_mount_binds`, hardcoded at `docker_vm.py` `_mount_binds()`); only the -vendor-side bridge/fix passes skip it. +With `GNS3_SKIP_INIT`, GNS3's hardcoded `/etc/network` volume (see +`docker_vm.py` `_mount_binds()`) is dropped entirely by +`VendorDockerVM._mount_binds()`: it holds GNS3's own network config for +init.sh's `ifup`, which never runs for SKIP_INIT containers — the NOS +manages its own interfaces. The override removes the bind, filters the +volume out of `self._volumes`, and deletes the host-side skeleton directory +the base class just created. Without `GNS3_SKIP_INIT` the mount is kept +(behaviour matches the base class). ### Lifecycle summary diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index 1c80eac7c..9d0c683f6 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -27,9 +27,11 @@ container behaves identically to DockerVM. """ import asyncio +import contextlib import json import logging import os +import shutil from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer from gns3server.compute.docker.docker_vm import DockerVM @@ -79,6 +81,26 @@ class VendorDockerVM(DockerVM): # ---- hook overrides --------------------------------------------------- + def _mount_binds(self, image_info): + """ + Override: for SKIP_INIT containers, drop GNS3's hardcoded + /etc/network volume. It holds GNS3's own network config consumed by + init.sh's `ifup`; init.sh never runs for SKIP_INIT containers (the + NOS manages its own interfaces), so the mount would be dead weight. + Removes the bind, drops the volume from self._volumes (so + GNS3_VOLUMES and the vendor passes stay consistent) and deletes the + host-side skeleton directory the base class just created. + """ + binds = super()._mount_binds(image_info) + if self._gns3_init: + return binds + binds = [b for b in binds if b.get("Target") != "/gns3volumes/etc/network"] + self._volumes = [v for v in self._volumes if v != "/etc/network"] + shutil.rmtree(os.path.join(self.working_dir, "etc", "network"), ignore_errors=True) + with contextlib.suppress(OSError): + os.rmdir(os.path.join(self.working_dir, "etc")) + return binds + def _prepare_init_and_interface_env(self, params): """ Override: conditionally prepend init.sh, and honour @@ -95,19 +117,6 @@ class VendorDockerVM(DockerVM): last_ifname = f"eth{self.adapters - 1}" params["Env"].append(f"GNS3_MAX_ETHERNET={last_ifname}") - def _persistent_volumes(self): - """ - Volumes relevant to vendor persistence. With GNS3_SKIP_INIT, drop - /etc/network — GNS3's own network config for init.sh's ifup, unused - when init.sh is skipped (the NOS manages its own interfaces). The - Docker mount itself is left alone (shared _mount_binds); only the - vendor-side bridge/fix passes skip it. Without SKIP_INIT the full - list is returned so behaviour matches the base class. - """ - if self._gns3_init: - return self._volumes - return [v for v in self._volumes if v != "/etc/network"] - def _get_container_ifname(self, adapter_number): """ Override: honour GNS3_INTERFACE_NAMES (e.g. mgmt0, e1-1) in adapter @@ -170,7 +179,7 @@ class VendorDockerVM(DockerVM): return uid, gid = os.getuid(), os.getgid() - for volume in self._persistent_volumes(): + for volume in self._volumes: target = f"/gns3volumes{volume}" log.debug("Docker container '%s' fix ownership on %s", self._name, target) try: @@ -212,7 +221,7 @@ class VendorDockerVM(DockerVM): Permission-changes recorded by _fix_permissions at the previous stop are restored (best-effort). """ - for volume in self._persistent_volumes(): + for volume in self._volumes: vol_target = f"/gns3volumes{volume}" # fmt: off script = ( From 1e154ac85f5ff9c0dd872a73c94802525d4dd392 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 12 Aug 2026 23:59:50 +0800 Subject: [PATCH 11/15] test: add tests for VendorDockerVM and the docker_exec factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 25 tests covering: - Docker.create_node factory: selects VendorDockerVM iff console_type == docker_exec, DockerVM otherwise (including telnet/ssh/vnc/http/none/spice) - GNS3_* env parsing: SKIP_INIT, INTERFACE_NAMES, CONSOLE_CMD (single and multiline), defaults - create(): init.sh skipped under GNS3_SKIP_INIT, prepended otherwise; GNS3_MAX_ETHERNET follows the interface rename; /etc/network mount dropped under SKIP_INIT (and host skeleton dir removed) but kept without it - _add_ubridge_connection: move_to_ns targets the renamed interface (mgmt0) or falls back to eth{N} - start(): docker_exec console dispatch + SKIP_INIT volume bridge + permission fix; without SKIP_INIT the vendor passes are skipped - _fix_permissions: skips dead/missing containers (no restart), targets /gns3volumes bind-mount paths - _setup_skip_init_volumes: runs the docker exec bridge script - _cleanup_console_resources: closes the exec pty writer Full Docker suite (111) and compute suite (395) pass — the four hook extractions in DockerVM introduce no regressions. --- tests/compute/docker/test_vendor_docker_vm.py | 459 ++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100644 tests/compute/docker/test_vendor_docker_vm.py diff --git a/tests/compute/docker/test_vendor_docker_vm.py b/tests/compute/docker/test_vendor_docker_vm.py new file mode 100644 index 000000000..ba444bb79 --- /dev/null +++ b/tests/compute/docker/test_vendor_docker_vm.py @@ -0,0 +1,459 @@ +# +# Copyright (C) 2025 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Tests for the VendorDockerVM subclass (vendor NOS containers, e.g. Nokia SR +Linux) and the Docker manager's class-selection factory. + +These tests cover: + * the factory selecting VendorDockerVM iff console_type == "docker_exec"; + * GNS3_* env parsing (SKIP_INIT, INTERFACE_NAMES, CONSOLE_CMD); + * init.sh prepend being skipped with GNS3_SKIP_INIT; + * GNS3_INTERFACE_NAMES renaming injected interfaces (move_to_ns target); + * the hardcoded /etc/network mount being dropped for SKIP_INIT containers; + * the docker_exec console dispatch in start(); + * the SKIP_INIT volume bridge and container-side _fix_permissions passes. +""" + +import uuid +import os + +import pytest +import pytest_asyncio + +from unittest.mock import patch, MagicMock, call + +from tests.utils import asyncio_patch, AsyncioMagicMock + +from gns3server.compute.docker import Docker +from gns3server.compute.docker.docker_vm import DockerVM +from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM +from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error + + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + +def _create_response(vm, entrypoint=None, volumes=None): + """Build the Docker /containers/create response (with image info merged).""" + return { + "Id": "e90e34656806", + "Warnings": [], + "Config": { + "Entrypoint": entrypoint, + "Cmd": [], + "Volumes": volumes, + }, + } + + +@pytest_asyncio.fixture +async def manager(port_manager): + + m = Docker.instance() + m.port_manager = port_manager + return m + + +def _make_vm(compute_project, manager, environment=None, console_type="docker_exec", + extra_volumes=None, adapters=4): + """Build a VendorDockerVM with a fake cid (no create() called).""" + vm = VendorDockerVM( + "srlinux-1", str(uuid.uuid4()), compute_project, manager, "srlinux:latest", + console_type=console_type, environment=environment, + extra_volumes=extra_volumes or [], adapters=adapters, + ) + vm._cid = "e90e34656842" + return vm + + +# --------------------------------------------------------------------------- +# Factory selection +# --------------------------------------------------------------------------- + +def test_factory_selects_vendor_when_docker_exec(manager): + + assert manager._select_node_class(console_type="docker_exec") is VendorDockerVM + + +def test_factory_selects_base_for_other_console_types(manager): + + for ct in ("telnet", "ssh", "vnc", "http", "https", "none", "spice"): + assert manager._select_node_class(console_type=ct) is DockerVM, ct + + +def test_factory_default_is_base(manager): + + assert manager._select_node_class() is DockerVM + + +@pytest.mark.asyncio +async def test_create_node_sets_node_class(manager, compute_project, monkeypatch): + """create_node() must switch _NODE_CLASS based on console_type.""" + + captured = {} + + async def fake_super_create_node(name, project_id, node_id, *args, **kwargs): + # record which class create_node selected before delegating + captured["cls"] = manager._NODE_CLASS + return None + + monkeypatch.setattr( + "gns3server.compute.base_manager.BaseManager.create_node", + fake_super_create_node, + ) + + await manager.create_node("v", compute_project.id, str(uuid.uuid4()), + "srlinux:latest", console_type="docker_exec") + assert captured["cls"] is VendorDockerVM + + await manager.create_node("v", compute_project.id, str(uuid.uuid4()), + "ubuntu:latest", console_type="telnet") + assert captured["cls"] is DockerVM + + +# --------------------------------------------------------------------------- +# GNS3_* env parsing +# --------------------------------------------------------------------------- + +def test_env_skip_init_true(compute_project, manager): + + vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1") + assert vm._gns3_init is False + + +def test_env_skip_init_absent_defaults_true(compute_project, manager): + + vm = _make_vm(compute_project, manager, environment="FOO=bar") + assert vm._gns3_init is True + + +def test_env_interface_names(compute_project, manager): + + vm = _make_vm(compute_project, manager, + environment="GNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3") + assert vm._interface_names == ["mgmt0", "e1-1", "e1-2", "e1-3"] + + +def test_env_console_cmd(compute_project, manager): + + vm = _make_vm(compute_project, manager, + environment="GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli") + assert vm._console_cmd == "/opt/srlinux/bin/sr_cli" + + +def test_env_multiple_lines(compute_project, manager): + + vm = _make_vm(compute_project, manager, + environment=("GNS3_SKIP_INIT=1\n" + "GNS3_INTERFACE_NAMES=mgmt0,e1-1\n" + "GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli\n")) + assert vm._gns3_init is False + assert vm._interface_names == ["mgmt0", "e1-1"] + assert vm._console_cmd == "/opt/srlinux/bin/sr_cli" + + +def test_env_console_cmd_default_none(compute_project, manager): + + vm = _make_vm(compute_project, manager) + assert vm._console_cmd is None + + +# --------------------------------------------------------------------------- +# create() — init.sh skip, GNS3_MAX_ETHERNET, /etc/network drop +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_create_skip_init_omits_init_sh(compute_project, manager): + + response = _create_response(None, entrypoint=["/init"]) + with asyncio_patch("gns3server.compute.docker.Docker.list_images", + return_value=[{"image": "srlinux"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", + return_value=response) as mock: + vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project, + manager, "srlinux:latest", + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1") + await vm.create() + # the Entrypoint must NOT contain /gns3/init.sh + sent = mock.call_args.kwargs["data"] + assert "/gns3/init.sh" not in sent["Entrypoint"] + assert sent["Entrypoint"] == ["/init"] + + +@pytest.mark.asyncio +async def test_create_without_skip_init_prepends_init_sh(compute_project, manager): + + response = _create_response(None, entrypoint=["/init"]) + with asyncio_patch("gns3server.compute.docker.Docker.list_images", + return_value=[{"image": "srlinux"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", + return_value=response) as mock: + vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project, + manager, "srlinux:latest", + console_type="docker_exec") + await vm.create() + sent = mock.call_args.kwargs["data"] + # init.sh IS prepended when not skipping + assert sent["Entrypoint"][0] == "/gns3/init.sh" + + +@pytest.mark.asyncio +async def test_create_interface_names_sets_max_ethernet(compute_project, manager): + + response = _create_response(None) + with asyncio_patch("gns3server.compute.docker.Docker.list_images", + return_value=[{"image": "srlinux"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", + return_value=response) as mock: + vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project, + manager, "srlinux:latest", adapters=4, + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1\nGNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3") + await vm.create() + sent = mock.call_args.kwargs["data"] + # last interface (adapter index 3) should be e1-3, not eth3 + assert any(v == "GNS3_MAX_ETHERNET=e1-3" for v in sent["Env"]) + + +@pytest.mark.asyncio +async def test_create_drops_etc_network_for_skip_init(compute_project, manager): + + response = _create_response(None, volumes={"/opt/srlinux/appmgr": None}) + with asyncio_patch("gns3server.compute.docker.Docker.list_images", + return_value=[{"image": "srlinux"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", + return_value=response) as mock: + vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project, + manager, "srlinux:latest", + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1", + extra_volumes=["/etc/opt/srlinux"]) + await vm.create() + sent = mock.call_args.kwargs["data"] + targets = [m["Target"] for m in sent["HostConfig"]["Mounts"]] + # /etc/network must NOT be mounted + assert "/gns3volumes/etc/network" not in targets + # but the declared volumes ARE mounted + assert "/gns3volumes/opt/srlinux/appmgr" in targets + assert "/gns3volumes/etc/opt/srlinux" in targets + # GNS3_VOLUMES env must also exclude /etc/network + vol_env = [v for v in sent["Env"] if v.startswith("GNS3_VOLUMES=")][0] + assert "/etc/network" not in vol_env + # host skeleton dir removed + assert not os.path.exists(os.path.join(vm.working_dir, "etc", "network")) + + +@pytest.mark.asyncio +async def test_create_keeps_etc_network_without_skip_init(compute_project, manager): + + response = _create_response(None) + with asyncio_patch("gns3server.compute.docker.Docker.list_images", + return_value=[{"image": "srlinux"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", + return_value=response) as mock: + vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project, + manager, "srlinux:latest", + console_type="docker_exec") + await vm.create() + sent = mock.call_args.kwargs["data"] + targets = [m["Target"] for m in sent["HostConfig"]["Mounts"]] + assert "/gns3volumes/etc/network" in targets + + +# --------------------------------------------------------------------------- +# Interface renaming (_get_container_ifname / move_to_ns) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_move_to_ns_uses_renamed_interface(compute_project, manager): + + vm = _make_vm(compute_project, manager, + environment="GNS3_SKIP_INIT=1\nGNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3") + vm._ubridge_hypervisor = MagicMock() + vm._namespace = 42 + nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}) + await vm._add_ubridge_connection(nio, 0) + # adapter 0 should be renamed to mgmt0 + move_calls = [c for c in vm._ubridge_hypervisor.method_calls if "move_to_ns" in str(c)] + assert move_calls, "move_to_ns was not sent" + assert call.send("docker move_to_ns tap-gns3-e0 42 mgmt0") in move_calls + + +@pytest.mark.asyncio +async def test_move_to_ns_falls_back_to_eth(compute_project, manager): + + vm = _make_vm(compute_project, manager) # no INTERFACE_NAMES + vm._ubridge_hypervisor = MagicMock() + vm._namespace = 42 + nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}) + await vm._add_ubridge_connection(nio, 1) + move_calls = [c for c in vm._ubridge_hypervisor.method_calls if "move_to_ns" in str(c)] + assert call.send("docker move_to_ns tap-gns3-e0 42 eth1") in move_calls + + +# --------------------------------------------------------------------------- +# start() — docker_exec console dispatch + volume bridge + permission fix +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_start_docker_exec_dispatches_console(compute_project, manager): + + vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1") + vm.adapters = 1 + vm._get_container_state = AsyncioMagicMock(return_value="stopped") + vm._start_ubridge = AsyncioMagicMock() + vm._get_namespace = AsyncioMagicMock(return_value=42) + vm._add_ubridge_connection = AsyncioMagicMock() + vm._start_docker_exec_console = AsyncioMagicMock() + vm._setup_skip_init_volumes = AsyncioMagicMock() + vm._fix_permissions = AsyncioMagicMock() + + with patch("gns3server.compute.docker.Docker.install_busybox"): + with asyncio_patch("gns3server.compute.docker.Docker.query"): + await vm.start() + + vm._start_docker_exec_console.assert_called_once() + assert vm.status == "started" + # SKIP_INIT path runs the volume bridge + permission fix + vm._setup_skip_init_volumes.assert_called_once() + vm._fix_permissions.assert_called_once() + + +@pytest.mark.asyncio +async def test_start_without_skip_init_skips_vendor_passes(compute_project, manager): + + vm = _make_vm(compute_project, manager) # no SKIP_INIT + vm.adapters = 1 + vm.console_type = "docker_exec" + vm._get_container_state = AsyncioMagicMock(return_value="stopped") + vm._start_ubridge = AsyncioMagicMock() + vm._get_namespace = AsyncioMagicMock(return_value=42) + vm._add_ubridge_connection = AsyncioMagicMock() + vm._start_docker_exec_console = AsyncioMagicMock() + vm._setup_skip_init_volumes = AsyncioMagicMock() + vm._fix_permissions = AsyncioMagicMock() + + with patch("gns3server.compute.docker.Docker.install_busybox"): + with asyncio_patch("gns3server.compute.docker.Docker.query"): + await vm.start() + + # init.sh runs (no SKIP_INIT) → no vendor bridge/fix passes + vm._setup_skip_init_volumes.assert_not_called() + + +# --------------------------------------------------------------------------- +# _fix_permissions — container-side, skips dead containers, targets /gns3volumes +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_fix_permissions_skips_dead_container(compute_project, manager): + + vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1") + vm._volumes = ["/etc/opt/srlinux"] + vm._get_container_state = AsyncioMagicMock(return_value="exited") + + with patch("asyncio.subprocess.create_subprocess_exec") as mock_exec: + await vm._fix_permissions() + # must NOT exec into a dead container + mock_exec.assert_not_called() + + +@pytest.mark.asyncio +async def test_fix_permissions_skips_missing_container(compute_project, manager): + + vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1") + vm._volumes = ["/etc/opt/srlinux"] + vm._get_container_state = AsyncioMagicMock(side_effect=DockerHttp404Error("nope")) + + with patch("asyncio.subprocess.create_subprocess_exec") as mock_exec: + await vm._fix_permissions() + mock_exec.assert_not_called() + + +@pytest.mark.asyncio +async def test_fix_permissions_targets_gns3volumes(compute_project, manager): + + vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1") + vm._volumes = ["/etc/opt/srlinux", "/var/log/srlinux"] + vm._get_container_state = AsyncioMagicMock(return_value="running") + + proc = MagicMock() + proc.wait = AsyncioMagicMock(return_value=0) + proc.returncode = 0 + proc.stderr = MagicMock() + proc.stderr.read = AsyncioMagicMock(return_value=b"") + + with patch("asyncio.subprocess.create_subprocess_exec", + return_value=proc) as mock_exec: + await vm._fix_permissions() + # one exec per volume + assert mock_exec.call_count == 2 + # each script must target /gns3volumes, not the raw path + for call_obj in mock_exec.call_args_list: + script = call_obj.args[-1] # last positional arg is the sh -c script + assert "/gns3volumes" in script + # must NOT chown the in-container path directly + assert 'chown' in script and '"/gns3volumes' in script + + +# --------------------------------------------------------------------------- +# _setup_skip_init_volumes — bridge via docker exec +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_setup_skip_init_volumes_runs_exec(compute_project, manager): + + vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1", + extra_volumes=["/etc/opt/srlinux"]) + vm._volumes = ["/etc/opt/srlinux"] + + proc = MagicMock() + proc.communicate = AsyncioMagicMock(return_value=(b"", b"")) + proc.returncode = 0 + + with patch("asyncio.subprocess.create_subprocess_exec", + return_value=proc) as mock_exec: + await vm._setup_skip_init_volumes() + assert mock_exec.call_count == 1 + script = mock_exec.call_args.args[-1] + # must do the bind mount + assert "mount --bind" in script + assert "/gns3volumes/etc/opt/srlinux" in script + + +# --------------------------------------------------------------------------- +# _cleanup_console_resources +# --------------------------------------------------------------------------- + +def test_cleanup_console_resources_closes_writer(compute_project, manager): + + vm = _make_vm(compute_project, manager) + writer = MagicMock() + vm._console_exec_writer = writer + vm._cleanup_console_resources() + writer.close.assert_called_once() + assert vm._console_exec_writer is None + + +def test_cleanup_console_resources_no_writer(compute_project, manager): + + vm = _make_vm(compute_project, manager) + vm._console_exec_writer = None + # must not raise + vm._cleanup_console_resources() From 8889cea38b57d2d3ce5b40c49dc28f424e4affad Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 13 Aug 2026 00:13:21 +0800 Subject: [PATCH 12/15] fix: recreate docker_exec console on reconnect after CLI exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reconnect-blank-screen bug: when sr_cli exited (quit / idle timeout / crash) the while-true wrapper restarted it mid-session with no client attached, so its startup CPR probe (\e[6n) went unanswered and the TUI degraded/blocked. On reconnect lazy_started=True skipped recreation, so the client saw a blank screen. Fix: drop the while-true wrapper. Now when the CLI exits, the exec pty closes (EOF), the broadcast task ends, and the next client connection detects the dead upstream via _upstream_alive() and recreates the exec — with a terminal attached, so CPR is answered. A live exec is reused (just a Ctrl-L redraw). _LazyExecTelnetServer is extracted from a closure to module level so the reconnect/recreate logic is unit-testable. Add 9 tests covering _upstream_alive states and the recreate-on-death / reuse-if-live / close-half-dead-writer / no-while-true behaviors. Full Docker suite (120) passes. --- docs/features/docker-exec-console.md | 39 ++- gns3server/compute/docker/vendor_docker_vm.py | 272 ++++++++++-------- tests/compute/docker/test_vendor_docker_vm.py | 157 +++++++++- 3 files changed, 338 insertions(+), 130 deletions(-) diff --git a/docs/features/docker-exec-console.md b/docs/features/docker-exec-console.md index dd741ff87..b55fe68d0 100644 --- a/docs/features/docker-exec-console.md +++ b/docs/features/docker-exec-console.md @@ -124,11 +124,31 @@ console. Key points: CLI"* otherwise), and `Env: ["TERM=xterm"]` (the TUI library needs a recognised terminal). -3. **while-true wrapper.** The command is wrapped in - `sh -c "while true; do ; done"` so that when the CLI exits (user types - `quit`, or the NOS's own idle timeout logs the session out), a fresh CLI - instance starts in the same pty instead of killing the shared console - session. +3. **No while-true wrapper.** The command runs as `sh -c ""` (no + restart loop). When the CLI exits (`quit`, the NOS's own idle timeout, or a + crash) the exec pty closes, the broadcast task ends, and the next client + connection **recreates** the exec (see *Reconnection*). A `while true` + wrapper would restart the CLI mid-session with no client attached to + answer its startup CPR probe, producing a blank/degraded screen on + reconnect. + +### Reconnection + +The exec is created lazily and **recreated on reconnect if it has died**. +`client_connected_hook` checks `_upstream_alive()` (exec id set, writer open, +broadcast task not done) before each connect: + +- **First connect / dead upstream** → (re)create the exec. Because a client is + now attached, the CLI's startup CPR probe is answered by xterm.js → full + TUI. A half-dead writer is closed first to avoid a socket leak. +- **Live upstream** → reuse the existing exec, just send `Ctrl-L` to redraw + for the new client. + +This is what makes the console survive `quit`, idle timeout, and CLI +crashes: the death is detected (pty EOF ends the broadcast task) and the +next connection spins up a fresh exec with a terminal present. The +`_LazyExecTelnetServer` is extracted to module level specifically so this +reconnect logic is unit-tested. 4. **Hijacked raw-HTTP start.** The exec is started with `POST exec/{eid}/start` sent as a raw HTTP upgrade over the Docker unix @@ -324,9 +344,11 @@ present on the host. issue. **7. "Session has been idle, will logout in 300 seconds" → Connection closed** -- SR Linux's own CLI idle timeout. The while-true wrapper restarts the CLI - automatically, but to keep a permanent session disable the timeout in the - CLI: `enter candidate` → `/system cli idle-timeout disable` → `commit now`. +- SR Linux's own CLI idle timeout logs the CLI out, the exec pty closes, and + the console disconnects. Reopening the console recreates the exec (see + *Reconnection*) and gives a fresh login. To keep a permanent session, + disable the timeout in the CLI: `enter candidate` → + `/system cli idle-timeout disable` → `commit now`. **8. Controller logs `Permission denied` reading files under the node's project directory while the node runs** @@ -400,6 +422,7 @@ present on the host. | Version | Date | Changes | |---------|------|---------| +| 1.4 | 2026-08-13 | Reconnect fix: drop the while-true wrapper (it restarted the CLI with no client to answer CPR → blank screen on reconnect); the exec is now recreated on connect when the upstream has died. `_LazyExecTelnetServer` extracted to module level and unit-tested. | | 1.3 | 2026-08-12 | Document runtime ownership safety (root processes, self-healing daemons, ACL evidence for SR Linux), the boot-ordering caveat (bridge after boot → verify save/stop/start closed loop), and troubleshooting #10. | | 1.2 | 2026-08-12 | `_fix_permissions` rewritten: container-side (as root) on the `/gns3volumes` bind-mount targets instead of host-side — host-side chown cannot touch root-owned files when GNS3 is unprivileged. Dead containers are skipped instead of restarted. | | 1.1 | 2026-08-12 | Refactor: vendor logic extracted from `DockerVM` into `VendorDockerVM` subclass with 4 hook points + class-selection factory. Add SKIP_INIT volume persistence (`_setup_skip_init_volumes` + `_fix_permissions`) and lifecycle comparison. Add troubleshooting entries for idle timeout and permission-denied files. | diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index 9d0c683f6..f0410d196 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -288,127 +288,7 @@ class VendorDockerVM(DockerVM): 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() - srv._log_name = f"docker_exec console '{vm.name}'" - - async def _on_naws(srv, columns, rows): - 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 run(srv, network_reader, network_writer): - """Catch and log any exception that kills the client session.""" - try: - await super().run(network_reader, network_writer) - except Exception as exc: - log.warning(f"{srv._log_name}: client session terminated: {exc}", exc_info=True) - - 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": ["sh", "-c", f"while true; do {command}; done"], - }, - ) - srv._exec_id = result["Id"] - log.info(f"{srv._log_name}: exec created ({srv._exec_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] - log.info(f"{srv._log_name}: hijacked start -> {status_line.decode(errors='ignore')}") - 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()) - log.info(f"{srv._log_name}: broadcast task started, upstream wired, ready") - - async def client_connected_hook(srv): - await super().client_connected_hook() - log.info(f"{srv._log_name}: client connected, lazy_started={srv._started}") - async with srv._lock: - if not srv._started: - try: - await srv._create_exec() - except Exception as exc: - log.warning(f"{srv._log_name}: failed to create exec: {exc}", exc_info=True) - raise - 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 as exc: - log.warning(f"{srv._log_name}: Ctrl-L write failed: {exc}") - log.info(f"{srv._log_name}: client_connected_hook done") - - telnet = _LazyExecTelnetServer() + telnet = _LazyExecTelnetServer(self, self.manager, self._cid, self._console_cmd or "/bin/sh") try: self._telnet_servers.append( await telnet.start(self._manager.port_manager.console_host, self.console) @@ -418,3 +298,153 @@ class VendorDockerVM(DockerVM): 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}") + + +class _LazyExecTelnetServer(AsyncioTelnetServer): + """Telnet console whose docker exec (pty + command) is created lazily on + the first client connection and recreated if the upstream dies. + + Extracted to module level (rather than a closure inside + _start_docker_exec_console) so the reconnect/recreate logic is unit-testable. + + Lifecycle: the exec is created on the first connect. When the CLI exits + (quit / idle timeout / crash) the exec pty closes, the broadcast task ends, + and the *next* client connection recreates the exec — with a terminal + attached, so the CLI's startup CPR probe is answered. No ``while true`` + wrapper: that would restart the CLI mid-session with no client to answer + CPR, producing a blank/degraded screen on reconnect. + """ + + def __init__(self, vm, manager, cid, command): + super().__init__( + reader=None, + writer=None, + binary=True, + echo=False, + naws=True, + window_size_changed_callback=self._on_naws, + ) + self._vm = vm + self._manager = manager + self._cid = cid + self._command = command + self._exec_id = None + self._broadcast_task = None + self._lock = asyncio.Lock() + self._log_name = f"docker_exec console '{vm.name}'" + + def _upstream_alive(self): + """True if the exec pty + broadcast task are still pumping.""" + if self._exec_id is None or self._writer is None: + return False + if self._writer.is_closing(): + return False + if self._broadcast_task is not None and self._broadcast_task.done(): + return False + return True + + async def _on_naws(self, columns, rows): + if self._exec_id: + try: + await self._manager.query( + "POST", + f"exec/{self._exec_id}/resize", + params={"h": str(rows), "w": str(columns)}, + ) + except DockerError: + pass + + async def run(self, network_reader, network_writer): + """Catch and log any exception that kills the client session.""" + try: + await super().run(network_reader, network_writer) + except Exception as exc: + log.warning(f"{self._log_name}: client session terminated: {exc}", exc_info=True) + + async def _create_exec(self): + # create exec with a pty; run as root (vendor CLIs reject the image's + # default unprivileged user) and export TERM=xterm. + result = await self._manager.query( + "POST", + f"containers/{self._cid}/exec", + data={ + "AttachStdin": True, + "AttachStdout": True, + "AttachStderr": True, + "Tty": True, + "User": "root", + "Env": ["TERM=xterm"], + "Cmd": ["sh", "-c", self._command], + }, + ) + self._exec_id = result["Id"] + log.info(f"{self._log_name}: exec created ({self._exec_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(self._manager._server_url) + body = json.dumps({"Detach": False, "Tty": True}) + request = ( + f"POST /v{self._manager._api_version}/exec/{self._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] + log.info(f"{self._log_name}: hijacked start -> {status_line.decode(errors='ignore')}") + 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. + self._reader = reader + self._writer = writer + self._vm._console_exec_writer = writer # for stop() cleanup + self._broadcast_task = asyncio.create_task(self._broadcast_from_upstream()) + log.info(f"{self._log_name}: broadcast task started, upstream wired, ready") + + async def client_connected_hook(self): + await super().client_connected_hook() + async with self._lock: + # (Re)create the exec if it was never created or has died (CLI + # exited → pty EOF → broadcast task ended). Doing this with a + # client attached means the CLI's startup CPR probe is answered by + # a real terminal. + if not self._upstream_alive(): + log.info(f"{self._log_name}: client connected, (re)creating exec") + # close a half-dead writer before replacing it + if self._writer is not None and not self._writer.is_closing(): + with contextlib.suppress(Exception): + self._writer.close() + try: + await self._create_exec() + except Exception as exc: + log.warning(f"{self._log_name}: failed to create exec: {exc}", exc_info=True) + raise + try: + await self._on_naws(80, 24) # initial size before NAWS + except Exception: + pass + else: + log.info(f"{self._log_name}: client connected, reusing live exec") + # ask the TUI to (re)draw for the client that just connected. + if self._writer: + try: + self._writer.write(b"\x0c") # Ctrl-L -> TUI redraws + await self._writer.drain() + except Exception as exc: + log.warning(f"{self._log_name}: Ctrl-L write failed: {exc}") + log.info(f"{self._log_name}: client_connected_hook done") diff --git a/tests/compute/docker/test_vendor_docker_vm.py b/tests/compute/docker/test_vendor_docker_vm.py index ba444bb79..ce81e5119 100644 --- a/tests/compute/docker/test_vendor_docker_vm.py +++ b/tests/compute/docker/test_vendor_docker_vm.py @@ -40,7 +40,7 @@ from tests.utils import asyncio_patch, AsyncioMagicMock from gns3server.compute.docker import Docker from gns3server.compute.docker.docker_vm import DockerVM -from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM +from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM, _LazyExecTelnetServer from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error @@ -457,3 +457,158 @@ def test_cleanup_console_resources_no_writer(compute_project, manager): vm._console_exec_writer = None # must not raise vm._cleanup_console_resources() + + +# --------------------------------------------------------------------------- +# _LazyExecTelnetServer — upstream aliveness + reconnect/recreate logic +# --------------------------------------------------------------------------- + +def _make_lazy_server(compute_project, manager): + """Build a _LazyExecTelnetServer with _create_exec mocked out (no docker).""" + vm = _make_vm(compute_project, manager, environment="GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli") + srv = _LazyExecTelnetServer(vm, manager, "e90e34656842", "/opt/srlinux/bin/sr_cli") + srv._create_exec = AsyncioMagicMock() + srv._on_naws = AsyncioMagicMock() + return srv + + +def _live_writer(): + """A writer mock that reports as open (not closing).""" + w = MagicMock() + w.is_closing.return_value = False + return w + + +def _dead_writer(): + """A writer mock that reports as closing (pty closed).""" + w = MagicMock() + w.is_closing.return_value = True + return w + + +def test_upstream_alive_never_created(compute_project, manager): + + srv = _make_lazy_server(compute_project, manager) + assert srv._upstream_alive() is False + + +def test_upstream_alive_writer_closing(compute_project, manager): + + srv = _make_lazy_server(compute_project, manager) + srv._exec_id = "abc" + srv._writer = _dead_writer() + srv._broadcast_task = MagicMock() + srv._broadcast_task.done.return_value = False + assert srv._upstream_alive() is False + + +def test_upstream_alive_broadcast_done(compute_project, manager): + + srv = _make_lazy_server(compute_project, manager) + srv._exec_id = "abc" + srv._writer = _live_writer() + srv._broadcast_task = MagicMock() + srv._broadcast_task.done.return_value = True # CLI exited → EOF → task ended + assert srv._upstream_alive() is False + + +def test_upstream_alive_live(compute_project, manager): + + srv = _make_lazy_server(compute_project, manager) + srv._exec_id = "abc" + srv._writer = _live_writer() + srv._broadcast_task = MagicMock() + srv._broadcast_task.done.return_value = False + assert srv._upstream_alive() is True + + +@pytest.mark.asyncio +async def test_first_connect_creates_exec(compute_project, manager): + + srv = _make_lazy_server(compute_project, manager) + # never created → must create + await srv.client_connected_hook() + srv._create_exec.assert_called_once() + + +@pytest.mark.asyncio +async def test_reconnect_live_exec_not_recreated(compute_project, manager): + """Reconnecting while the exec is alive must NOT recreate it.""" + + srv = _make_lazy_server(compute_project, manager) + srv._exec_id = "abc" + srv._writer = _live_writer() + srv._broadcast_task = MagicMock() + srv._broadcast_task.done.return_value = False + + await srv.client_connected_hook() + srv._create_exec.assert_not_called() + # Ctrl-L redraw is still sent to the live writer + srv._writer.write.assert_any_call(b"\x0c") + + +@pytest.mark.asyncio +async def test_reconnect_after_death_recreates_exec(compute_project, manager): + """The core reconnect fix: after the CLI exits (broadcast task done), + the next client connection recreates the exec so CPR gets answered.""" + + srv = _make_lazy_server(compute_project, manager) + # simulate a dead upstream: exec existed, but the pty closed / task ended + srv._exec_id = "old-exec" + srv._writer = _dead_writer() + srv._broadcast_task = MagicMock() + srv._broadcast_task.done.return_value = True + + await srv.client_connected_hook() + srv._create_exec.assert_called_once() + + +@pytest.mark.asyncio +async def test_reconnect_closes_half_dead_writer(compute_project, manager): + """If the writer is still open but the broadcast task died, the old writer + must be closed before a new exec is created (no socket leak).""" + + srv = _make_lazy_server(compute_project, manager) + srv._exec_id = "old-exec" + srv._writer = _live_writer() # still open, but... + srv._broadcast_task = MagicMock() + srv._broadcast_task.done.return_value = True # ...task ended + + await srv.client_connected_hook() + srv._writer.close.assert_called_once() + srv._create_exec.assert_called_once() + + +@pytest.mark.asyncio +async def test_create_exec_cmd_has_no_while_true(compute_project, manager): + """The command must NOT be wrapped in a while-true loop (regression guard: + while-true restarts the CLI with no client to answer CPR → blank screen).""" + + vm = _make_vm(compute_project, manager) + manager._server_url = "/var/run/docker.sock" + manager._api_version = "1.40" + srv = _LazyExecTelnetServer(vm, manager, "e90e34656842", "/opt/srlinux/bin/sr_cli") + + captured = {} + + async def fake_query(method, path, data=None, **kw): + captured["data"] = data + return {"Id": "exec123"} + + manager.query = fake_query + + with patch("asyncio.open_unix_connection") as mock_open: + reader = MagicMock() + reader.readuntil = AsyncioMagicMock(return_value=b"HTTP/1.1 101 Upgraded\r\n\r\n") + writer = MagicMock() + writer.is_closing.return_value = False + mock_open.return_value = (reader, writer) + await srv._create_exec() + + cmd = captured["data"]["Cmd"] + assert cmd == ["sh", "-c", "/opt/srlinux/bin/sr_cli"] + assert "while true" not in cmd[2] + # must run as root with a pty and TERM + assert captured["data"]["User"] == "root" + assert captured["data"]["Tty"] is True + assert "TERM=xterm" in captured["data"]["Env"] From 141b3d8701d73e3ba25186e008633577f7009de5 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 13 Aug 2026 00:46:48 +0800 Subject: [PATCH 13/15] appliance: accept docker_exec console type in Docker appliances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker appliance Pydantic model (DockerConsoleType) rejected console_type='docker_exec', so an appliance file using the vendor NOS docker_exec console could not be loaded (validation error at import). Add docker_exec to the enum — it is already a valid ConsoleType (schemas/common.py) and is handled by VendorDockerVM. --- gns3server/schemas/controller/appliances.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gns3server/schemas/controller/appliances.py b/gns3server/schemas/controller/appliances.py index 6a0ef5720..563f2f5fa 100644 --- a/gns3server/schemas/controller/appliances.py +++ b/gns3server/schemas/controller/appliances.py @@ -285,6 +285,7 @@ class DockerConsoleType(str, Enum): http = 'http' https = 'https' none = 'none' + docker_exec = 'docker_exec' class ChecksumType(str, Enum): From 9e830f788370e6a4b695ab38aa02e5c0c0c09e7a Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 13 Aug 2026 01:07:38 +0800 Subject: [PATCH 14/15] appliance: expose custom_adapters on the v1-6 appliance model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top-level ApplianceV1_6 model declared first_port_name / port_name_format / port_segment_size but not custom_adapters, so the GET /appliances endpoint (response_model=schemas.Appliance) stripped custom_adapters from the response — the frontend never saw per-adapter port names even though the appliance file and the server-side template conversion (appliance_to_template reads it from the raw dict) handled it. Add custom_adapters: Optional[List[CustomAdapterItem]] to ApplianceV1_6 so the field round-trips through the API. (ApplianceV8 already models it inside its TemplateSetting.) --- gns3server/schemas/controller/appliances.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gns3server/schemas/controller/appliances.py b/gns3server/schemas/controller/appliances.py index 563f2f5fa..23e7b32c0 100644 --- a/gns3server/schemas/controller/appliances.py +++ b/gns3server/schemas/controller/appliances.py @@ -632,6 +632,9 @@ class ApplianceV1_6(BaseModel): None, title='Optional port segment size. A port segment is a block of port. For example Ethernet0/0 Ethernet0/1 is the module 0 with a port segment size of 2', ) + custom_adapters: Optional[List[CustomAdapterItem]] = Field( + None, title='Optional per-adapter overrides (port name, adapter type, MAC address)' + ) linked_clone: Optional[bool] = Field(None, title="False if you don't want to use a single image for all nodes") docker: Optional[Docker] = Field(None, title='Docker specific options') iou: Optional[Iou] = Field(None, title='IOU specific options') From 64657918b56625f88996379abd6ee9e79982676b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 13 Aug 2026 01:39:38 +0800 Subject: [PATCH 15/15] docs: appliance packaging section + symbol theme caveat Document the SR Linux gns3a (35-adapter full chassis, matching GNS3_INTERFACE_NAMES + custom_adapters), the three server-side schema fixes needed for it to load (DockerConsoleType.docker_exec, ApplianceV1_6.custom_adapters, extra_volumes docker-block passthrough), and the symbol-theme behaviour that rewrites any :/symbols/-prefixed symbol to the category default at load time (so router_cloud.svg cannot be used from an appliance; use a custom symbol under symbols_path instead). --- docs/features/docker-exec-console.md | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/features/docker-exec-console.md b/docs/features/docker-exec-console.md index b55fe68d0..a121ea23c 100644 --- a/docs/features/docker-exec-console.md +++ b/docs/features/docker-exec-console.md @@ -207,6 +207,41 @@ The exec-API approach fixes all of these: a real pty (`Tty:true`), a real size (`{"adapter_number": 0, "port_name": "mgmt0"}`, …). Port labels are a controller-side concept, independent of the compute-side interface rename. +### Appliance (`gns3a`) packaging + +A SR Linux appliance lives in `gns3-registry/appliances/srlinux.gns3a` +(`registry_version: 6`). It sets the full chassis — **35 adapters** +(`mgmt0` + `e1-1`..`e1-34`) — with matching `GNS3_INTERFACE_NAMES` and 35 +`custom_adapters` entries (`mgmt0`, `e1-1`..`e1-34`) so the canvas labels, +the kernel interface names and the `ethernet-1/N` CLI names all line up. + +Three appliance-schema fixes are required for this appliance to load (all on +the gns3-server side; the registry JSON schema is unchanged because its docker +block allows `additionalProperties`): + +1. **`DockerConsoleType`** (`schemas/controller/appliances.py`) must include + `docker_exec`, or the Pydantic appliance model rejects the file at import. +2. **`ApplianceV1_6.custom_adapters`** must be declared on the top-level + appliance model, or `GET /appliances` (response_model=`schemas.Appliance`) + strips `custom_adapters` from the API response even though the file and the + server-side template conversion handle it. (Node creation still worked + because `appliance_to_template._add_docker_config` reads it from the raw + dict; only the GET response was lossy.) +3. `extra_volumes` rides inside the `docker` block (passed through by + `new_config.update(appliance_config["docker"])`); no schema change needed. + +> **Symbol theme caveat.** An appliance `symbol` that starts with +> `:/symbols/` is forcibly rewritten at load time +> (`appliance_manager._load_appliances`) to the current theme's default for the +> appliance category — so `:/symbols/affinity/circle/blue/router_cloud.svg` (or +> `router2.svg`) becomes `:/symbols/affinity/circle/blue/router.svg`, because +> the theme maps only the canonical name `"router"`. This is intentional: it +> lets theme switching re-skin every node consistently. To use a non-default +> icon (e.g. `router_cloud`), install it as a **custom symbol** under the +> configured `symbols_path` and reference it by filename (no `:/symbols/` +> prefix) — custom symbols do not participate in re-theming. The SR Linux +> appliance uses `router.svg`. + ### Persistent state For SR Linux, persist `/etc/opt/srlinux` (config / AAA users / TLS certs) and @@ -422,6 +457,7 @@ present on the host. | Version | Date | Changes | |---------|------|---------| +| 1.5 | 2026-08-13 | Add appliance (`gns3a`) packaging section: 35-adapter full-chassis design, the three server-side schema fixes (DockerConsoleType, ApplianceV1_6.custom_adapters, extra_volumes passthrough), and the symbol-theme caveat (any `:/symbols/` symbol is rewritten to the category default at load). | | 1.4 | 2026-08-13 | Reconnect fix: drop the while-true wrapper (it restarted the CLI with no client to answer CPR → blank screen on reconnect); the exec is now recreated on connect when the upstream has died. `_LazyExecTelnetServer` extracted to module level and unit-tested. | | 1.3 | 2026-08-12 | Document runtime ownership safety (root processes, self-healing daemons, ACL evidence for SR Linux), the boot-ordering caveat (bridge after boot → verify save/stop/start closed loop), and troubleshooting #10. | | 1.2 | 2026-08-12 | `_fix_permissions` rewritten: container-side (as root) on the `/gns3volumes` bind-mount targets instead of host-side — host-side chown cannot touch root-owned files when GNS3 is unprivileged. Dead containers are skipped instead of restarted. |