diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py index d0b27f81f..de29db5dc 100644 --- a/gns3server/api/routes/controller/nodes.py +++ b/gns3server/api/routes/controller/nodes.py @@ -694,13 +694,19 @@ async def ws_console( async def ws_receive(ws_console_compute): """ Receive WebSocket data from client and forward to compute console WebSocket. + Text frames carry terminal data; binary frames carry client control + messages (e.g. terminal size), forwarded as-is. """ try: while True: - data = await websocket.receive_text() - if data: - await ws_console_compute.send_str(data) + msg = await websocket.receive() + if msg["type"] == "websocket.disconnect": + break + if "text" in msg and msg["text"]: + await ws_console_compute.send_str(msg["text"]) + elif "bytes" in msg and msg["bytes"]: + await ws_console_compute.send_bytes(msg["bytes"]) except WebSocketDisconnect: await ws_console_compute.close() log.info( diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 8e3188c84..f22fa97c0 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -19,6 +19,9 @@ import os import stat import shutil import asyncio +import contextlib +import json +import struct import tempfile import psutil import platform @@ -561,6 +564,51 @@ class BaseNode: log.warning(f"Cannot connect to node {self.name} console server: {e}") return + def _parse_terminal_size_message(data: bytes): + """ + Binary control frames sent by WebSocket console clients to propagate + their terminal geometry: {"cols": int, "rows": int}. Terminal data + travels as text frames (xterm.js AttachAddon), so binary frames are + an unambiguous side channel. Returns (cols, rows) or None. + """ + + try: + message = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + return None + if not isinstance(message, dict): + return None + cols, rows = message.get("cols"), message.get("rows") + if ( + isinstance(cols, int) and not isinstance(cols, bool) + and isinstance(rows, int) and not isinstance(rows, bool) + and 2 <= cols <= 5000 + and 2 <= rows <= 100000 + ): + return cols, rows + return None + + async def resize_console(cols: int, rows: int) -> None: + """ + Propagate a client terminal resize to the node console stream: + SSH channels use a pty request update, telnet-based consoles + (including docker_exec) speak a NAWS subnegotiation to the console + telnet server, which resizes the underlying stream (e.g. the + docker exec pty). + """ + + if self._console_type == "ssh": + with contextlib.suppress(AttributeError): + ssh_process.change_terminal_size(cols, rows) + else: + telnet_writer.write( + bytes([255, 251, 31]) # IAC WILL NAWS + + bytes([255, 250, 31]) # IAC SB NAWS + + struct.pack("!HH", cols, rows).replace(b"\xff", b"\xff\xff") + + bytes([255, 240]) # IAC SE + ) + await telnet_writer.drain() + async def ws_forward(telnet_writer): try: @@ -571,6 +619,14 @@ class BaseNode: if "text" in msg and msg["text"]: data = msg["text"].encode() elif "bytes" in msg and msg["bytes"]: + size = _parse_terminal_size_message(msg["bytes"]) + if size is not None: + log.debug( + f"Console WebSocket client {websocket.client.host}:{websocket.client.port}" + f" resized terminal to {size[0]}x{size[1]}" + ) + await resize_console(*size) + continue data = msg["bytes"] else: continue diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index dbbdf340c..46c56ca6f 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -412,6 +412,17 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): 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): + await self._on_naws(511, 10000) + async def _on_naws(self, columns, rows): if self._exec_id: try: @@ -509,8 +520,9 @@ class _LazyExecTelnetServer(AsyncioTelnetServer): # (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'. - # Real NAWS clients resize to their own geometry right - # after connecting. + # WebUI clients resize to their real geometry right after + # connecting, via WS terminal-size control frames turned + # into NAWS by start_websocket_console. await self._on_naws(511, 10000) except Exception: pass