mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
The SKIP_INIT volume bridge replicated init.sh's seed + mount --bind script via docker exec *after* the container started. That copied the mechanism but not the invariant that makes init.sh safe — the entrypoint position, which guarantees the volume is in place before the application runs. The exec runs concurrently with the NOS boot, so whether the NOS loaded its persisted config or the overlay's factory copy was a timing race: - single node stop/start on an idle system won it (exec ~1s, SR Linux reads its startup config at ~2-4s) — the save/stop/start round-trip passed; - a server restart + project reload lost it (concurrent node starts queue on the Docker API, delaying the exec by seconds) — SR Linux booted factory while the persisted config.json sat intact on the host; - XRd was immune (systemd boots tens of seconds before XR touches /xr-storage), which is why the race was never observed on it. Replace the bridge entirely: - new DockerVM._prepare_volumes hook (no-op in the base class) runs in create() after the image is present, before the container is created; VendorDockerVM overrides it to seed each volume's host directory from the image (throwaway docker create container + docker cp -a, nothing executes). The .gns3_perms marker gates the seeding: a volume that ever started is never re-seeded, so saved configuration is never overwritten with factory content (also the upgrade path for existing nodes). - VendorDockerVM._mount_binds now binds the volumes directly at their real in-container paths (/etc/opt/srlinux) instead of /gns3volumes aliases, so the persisted config is visible to the NOS from the very first process. - _setup_skip_init_volumes and its start() call are gone; the container-side _fix_permissions targets the volume paths directly (the direct binds exist for the whole container lifetime, unlike the old bridge). The volume-list computation (validation + overlap de-duplication) moves into DockerVM._persistent_volume_list so create-time seeding and _mount_binds cannot drift apart.
638 lines
29 KiB
Python
638 lines
29 KiB
Python
#
|
|
# 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 <http://www.gnu.org/licenses/>.
|
|
|
|
"""
|
|
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 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
|
|
from gns3server.compute.docker.docker_error import DockerError, DockerHttp304Error, DockerHttp404Error
|
|
|
|
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``). Persistent volumes
|
|
are seeded host-side and bound directly at their real in-container
|
|
paths at create time (see ``_prepare_volumes`` / ``_mount_binds``), so
|
|
the NOS sees its saved configuration from the very first process —
|
|
no post-start mount pass that could race the NOS reading its config.
|
|
* ``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``).
|
|
* ``GNS3_CONSOLE_RESIZE=0`` — ignore client-driven console resizes
|
|
(WS terminal-size frames / telnet NAWS). Set for CLIs that page on the
|
|
PTY window size (IOS-XR): the exec PTY must stay at the tall
|
|
no-NAWS default for every client, including concurrent netmiko
|
|
sessions on the shared exec.
|
|
* ``GNS3_STOP_TIMEOUT=60`` — SIGTERM grace period in seconds when stopping
|
|
the container (default 60; Docker SIGKILLs once it expires).
|
|
"""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
self._console_exec_writer = None
|
|
# Parsed eagerly so _get_container_ifname can return the right name,
|
|
# and re-parsed on every create() so a PUT to the node's environment
|
|
# takes effect on the next (re)create instead of the next reload.
|
|
self._parse_vendor_environment()
|
|
|
|
def _parse_vendor_environment(self):
|
|
"""
|
|
(Re)parse the GNS3_* knobs from the current ``environment`` value,
|
|
resetting to defaults first so removed entries stop applying.
|
|
"""
|
|
|
|
self._gns3_init = True
|
|
self._interface_names = []
|
|
self._console_cmd = None
|
|
self._console_resize = True
|
|
self._stop_timeout = 60
|
|
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()
|
|
elif _line.startswith("GNS3_CONSOLE_RESIZE="):
|
|
self._console_resize = _line.split("=", 1)[1].strip().lower() not in ("0", "false", "no")
|
|
elif _line.startswith("GNS3_STOP_TIMEOUT="):
|
|
try:
|
|
timeout = int(_line.split("=", 1)[1].strip())
|
|
# Ceiling is derived from the call chain, not arbitrary:
|
|
# the controller's stop request times out at 240 s
|
|
# (controller/node.py) and the Docker stop query gets
|
|
# this value +30 s as its HTTP timeout — so anything
|
|
# above 210 would abort upstream first.
|
|
if 1 <= timeout <= 210:
|
|
self._stop_timeout = timeout
|
|
except ValueError:
|
|
pass
|
|
|
|
async def create(self):
|
|
# The environment may have changed since __init__ (PUT on the node) —
|
|
# re-parse the knobs so the recreated container picks them up.
|
|
self._parse_vendor_environment()
|
|
return await super().create()
|
|
|
|
# ---- 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.
|
|
|
|
Additionally, the persistent volumes are bound directly at their
|
|
real in-container paths instead of /gns3volumes<volume>. With
|
|
init.sh skipped there is no in-container mount pass, so a volume
|
|
bound at /gns3volumes would only be moved into place by a post-start
|
|
``docker exec`` — racing the NOS reading its startup configuration
|
|
(an SR Linux node booted factory whenever the exec lost that race,
|
|
e.g. on the concurrent node starts of a project reload). Binding at
|
|
the real path is safe because the content is seeded host-side before
|
|
the container is created (see _prepare_volumes): the image's files
|
|
are never shadowed by an empty mount.
|
|
"""
|
|
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"))
|
|
|
|
# Re-target the volume binds from /gns3volumes<volume> to <volume>.
|
|
retargeted = []
|
|
for bind in binds:
|
|
target = bind.get("Target", "")
|
|
if target.startswith("/gns3volumes"):
|
|
volume = target[len("/gns3volumes"):]
|
|
if volume in self._volumes:
|
|
bind = {**bind, "Target": volume}
|
|
retargeted.append(bind)
|
|
return retargeted
|
|
|
|
async def _prepare_volumes(self, image_info):
|
|
"""
|
|
Override: for SKIP_INIT containers, seed every persistent volume's
|
|
host directory with the image's original content *before* the
|
|
container is created. This is the host-side replacement of init.sh's
|
|
first-copy: because the volume is then bound directly at its real
|
|
in-container path (see _mount_binds), the seed must exist first or
|
|
the NOS would boot with an empty config directory.
|
|
|
|
``.gns3_perms`` doubles as the seeded marker: a volume that has it
|
|
(every node that ever started, on any GNS3 version) is never
|
|
re-seeded — a re-seed would overwrite the node's saved
|
|
configuration with the factory image content.
|
|
"""
|
|
if self._gns3_init:
|
|
return
|
|
volumes = self._persistent_volume_list(image_info, include_network_config=False)
|
|
to_seed = []
|
|
for volume in volumes:
|
|
host_dir = os.path.join(self.working_dir, os.path.relpath(volume, "/"))
|
|
os.makedirs(host_dir, exist_ok=True)
|
|
if not os.path.exists(os.path.join(host_dir, ".gns3_perms")):
|
|
to_seed.append((volume, host_dir))
|
|
if not to_seed:
|
|
return
|
|
seed_cid = await self._create_seed_container()
|
|
try:
|
|
for volume, host_dir in to_seed:
|
|
await self._seed_volume_from_container(seed_cid, volume, host_dir)
|
|
# Write the marker only after the copy attempt, mirroring
|
|
# init.sh: a volume without it is (re)seeded on the next
|
|
# create(), so a partial seed self-heals.
|
|
open(os.path.join(host_dir, ".gns3_perms"), "a").close()
|
|
finally:
|
|
await self._remove_seed_container(seed_cid)
|
|
|
|
async def _create_seed_container(self):
|
|
"""
|
|
A throwaway ``docker create`` container (nothing executes) used as
|
|
the copy source for seeding persistent volumes with the image's
|
|
original content.
|
|
"""
|
|
|
|
try:
|
|
process = await asyncio.subprocess.create_subprocess_exec(
|
|
"docker", "create", self._image,
|
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
except OSError as e:
|
|
raise DockerError(f"Could not seed persistent volumes for '{self._name}': {e}")
|
|
stdout, stderr = await process.communicate()
|
|
if process.returncode != 0:
|
|
raise DockerError(
|
|
f"Could not create a seeding container for image '{self._image}': "
|
|
f"{stderr.decode(errors='replace').strip()}"
|
|
)
|
|
return stdout.decode().strip()
|
|
|
|
async def _seed_volume_from_container(self, seed_cid, volume, host_dir):
|
|
"""
|
|
Copy one volume's original content from the seeding container to its
|
|
host directory with ``docker cp -a`` (preserves modes/ownership; no
|
|
dependency on tools inside the image).
|
|
"""
|
|
|
|
try:
|
|
process = await asyncio.subprocess.create_subprocess_exec(
|
|
"docker", "cp", "-a", f"{seed_cid}:{volume}/.", host_dir + "/",
|
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
except OSError as e:
|
|
raise DockerError(f"Could not seed persistent volume '{volume}' for '{self._name}': {e}")
|
|
_, stderr = await process.communicate()
|
|
if process.returncode != 0:
|
|
# A path the image does not contain (e.g. XRd's /xr-storage-shadow)
|
|
# is not an error: the volume starts empty. Same tolerance as
|
|
# init.sh's first copy (cp -a ... 2>/dev/null).
|
|
log.info(
|
|
"Persistent volume '%s' on '%s' not seedable from image '%s' (%s); starting empty",
|
|
volume, self._name, self._image, stderr.decode(errors="replace").strip(),
|
|
)
|
|
return
|
|
log.info("Seeded persistent volume '%s' for '%s' from image '%s'", volume, self._name, self._image)
|
|
|
|
async def _remove_seed_container(self, seed_cid):
|
|
"""
|
|
Best-effort removal of the seeding container.
|
|
"""
|
|
|
|
try:
|
|
process = await asyncio.subprocess.create_subprocess_exec(
|
|
"docker", "rm", "-f", seed_cid,
|
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
except OSError:
|
|
return
|
|
await process.communicate()
|
|
|
|
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 _terminate_container(self, graceful: bool = False):
|
|
"""
|
|
Override: vendor NOS containers run systemd and require a graceful
|
|
shutdown (e.g. Cisco XRd treats an abrupt SIGKILL as an unclean
|
|
shutdown).
|
|
|
|
With ``graceful`` (explicit user stop), send SIGTERM and wait up to
|
|
``GNS3_STOP_TIMEOUT`` seconds (default 60, 1-210 — the ceiling keeps
|
|
the +30 s HTTP margin inside the controller's 240 s stop budget) for
|
|
the services to stop; Docker SIGKILLs the container itself once the
|
|
grace period expires, so no fallback kill is needed.
|
|
|
|
Without ``graceful`` (delete/update/close/crash cleanup), fall back to
|
|
the base immediate kill: those paths force-delete or recreate the
|
|
container right after anyway, so a grace period buys nothing but
|
|
latency.
|
|
"""
|
|
if not graceful:
|
|
await super()._terminate_container(graceful=False)
|
|
return
|
|
try:
|
|
response = await self.manager.http_query(
|
|
"POST",
|
|
f"containers/{self._cid}/stop",
|
|
params={"t": self._stop_timeout},
|
|
timeout=self._stop_timeout + 30,
|
|
)
|
|
response.close()
|
|
except DockerHttp304Error:
|
|
pass # already stopped
|
|
|
|
async def start(self):
|
|
await super().start()
|
|
if self.status == "started" and not self._gns3_init:
|
|
# Persistent volumes are seeded and bound directly at create time
|
|
# (see _prepare_volumes / _mount_binds), so there is no post-start
|
|
# bridge to run. Fix host-side ownership right away so the
|
|
# controller can read project files while the node runs, and reset
|
|
# the "fixed" flag: 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):
|
|
"""
|
|
Container-side override of DockerVM._fix_permissions for vendor NOS
|
|
containers. The persistent volumes are Docker bind mounts created
|
|
with the container (see _mount_binds), so the in-container paths
|
|
resolve to the host-side files for the container's whole lifetime —
|
|
no /gns3volumes aliasing is needed.
|
|
|
|
The busybox script runs inside the container as root (a host-side
|
|
GNS3 process may be unprivileged and cannot chown root-owned files).
|
|
|
|
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:
|
|
target = volume
|
|
log.debug("Docker container '%s' fix ownership on %s", self._name, target)
|
|
try:
|
|
# chown prefers the container's own coreutils over /gns3/bin/busybox:
|
|
# busybox is static, and its chown dlopens NSS modules from the
|
|
# container, which mismatch the static glibc and abort (glibc
|
|
# "sym != NULL") on NOS images whose glibc differs from the host's
|
|
# (e.g. Cisco XRd). It falls back to busybox on minimal images that
|
|
# ship no chown. cp/chmod/find/stat don't use NSS, so stay busybox.
|
|
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' && ( command -v chown >/dev/null 2>&1 && chown {uid}:{gid} -R "{target}" || /gns3/bin/busybox chown {uid}:{gid} -R "{target}" )',
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
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 _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.
|
|
"""
|
|
|
|
telnet = _LazyExecTelnetServer(
|
|
self,
|
|
self.manager,
|
|
self._cid,
|
|
self._console_cmd or "/bin/sh",
|
|
allow_resize=self._console_resize,
|
|
)
|
|
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}")
|
|
|
|
|
|
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, allow_resize=True):
|
|
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._allow_resize = allow_resize
|
|
self._exec_id = None
|
|
self._client_size = None # size received while no exec existed yet
|
|
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 _disconnect_client(self, network_writer):
|
|
await super()._disconnect_client(network_writer)
|
|
# When the last client leaves, restore the tall no-NAWS default: a
|
|
# browser client resizes the exec to its own geometry (WS terminal
|
|
# size control frames -> NAWS), and the next non-NAWS client (netmiko,
|
|
# bare telnet) connecting to the still-live exec would otherwise
|
|
# inherit it and hit PTY-window paging (the IOS-XR --More-- trap).
|
|
if self._exec_id and not await self._get_connections_snapshot():
|
|
with contextlib.suppress(Exception):
|
|
self._client_size = None
|
|
await self._resize_exec(511, 10000)
|
|
|
|
async def _resize_exec(self, columns, rows):
|
|
if not self._exec_id:
|
|
# No exec yet (first client still inside client_connected_hook):
|
|
# remember the size — the hook applies it right after creation
|
|
# instead of the tall default, so it doesn't get overwritten.
|
|
self._client_size = (columns, rows)
|
|
return
|
|
try:
|
|
await self._manager.query(
|
|
"POST",
|
|
f"exec/{self._exec_id}/resize",
|
|
params={"h": str(rows), "w": str(columns)},
|
|
)
|
|
except DockerError:
|
|
pass
|
|
|
|
async def _on_naws(self, columns, rows):
|
|
# Client-driven resize (WS terminal-size control frames, telnet NAWS).
|
|
# Ignored for paging CLIs (GNS3_CONSOLE_RESIZE=0): with the exec shared
|
|
# by all clients, one browser resize would break concurrent netmiko
|
|
# sessions that rely on the tall no-paging geometry.
|
|
if not self._allow_resize:
|
|
return
|
|
await self._resize_exec(columns, rows)
|
|
|
|
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:
|
|
# Tall/wide default geometry before any NAWS arrives: a
|
|
# 24-row PTY makes CLIs that page on the PTY window size
|
|
# (e.g. the IOS-XR pager) park at --More-- for clients
|
|
# that never negotiate NAWS (netmiko, bare telnet).
|
|
# Width 511 matches netmiko's 'terminal width 511'.
|
|
# A size already pushed by this client (WS terminal-size
|
|
# control frames -> NAWS, racing the exec creation) wins
|
|
# over the default.
|
|
if self._client_size:
|
|
await self._resize_exec(*self._client_size)
|
|
else:
|
|
await self._resize_exec(511, 10000)
|
|
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")
|