mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
console: forward client terminal size over the console WebSocket
The docker_exec console defaults its exec PTY to 511x10000 (the no-NAWS
default that keeps the IOS-XR pager quiet for netmiko). A CPR-answering
client (xterm.js) on top of that tall canvas makes prompt_toolkit-based
CLIs (SR Linux sr_cli) re-emit their accumulated output on every
incremental render: ~145 KB instead of ~60 KB per command, visible in
the WebUI as full-screen clear/redraw flicker.
Let WebSocket console clients propagate their real terminal geometry:
binary frames {"cols": N, "rows": M} alongside text frames carrying
terminal data. The controller forwards binary frames (previously only
text was forwarded), and the compute side turns them into a NAWS
subnegotiation for telnet-based consoles (docker_exec included) or an
asyncssh pty size change for SSH consoles.
The docker_exec console restores the tall 511x10000 default when its
last client disconnects, so a later non-NAWS client (netmiko, bare
telnet) connecting to the still-live exec doesn't inherit a browser
geometry and hit PTY-window paging again.
This commit is contained in:
parent
5188ae625a
commit
abd0b8e274
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user