docs: document the docker_exec console and vendor NOS Docker knobs

This commit is contained in:
YueGuobin 2026-08-12 19:14:44 +08:00
parent 474dc1db64
commit 486178d05c
No known key found for this signature in database
2 changed files with 229 additions and 3 deletions

View File

@ -0,0 +1,209 @@
<!--
SPDX-License-Identifier: CC-BY-SA-4.0
See LICENSE file for licensing information.
-->
> 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 <container> <cli>` 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. |

View File

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