mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-13 21:45:55 +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):
|
async def ws_receive(ws_console_compute):
|
||||||
"""
|
"""
|
||||||
Receive WebSocket data from client and forward to compute console WebSocket.
|
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:
|
try:
|
||||||
while True:
|
while True:
|
||||||
data = await websocket.receive_text()
|
msg = await websocket.receive()
|
||||||
if data:
|
if msg["type"] == "websocket.disconnect":
|
||||||
await ws_console_compute.send_str(data)
|
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:
|
except WebSocketDisconnect:
|
||||||
await ws_console_compute.close()
|
await ws_console_compute.close()
|
||||||
log.info(
|
log.info(
|
||||||
|
|||||||
@ -19,6 +19,9 @@ import os
|
|||||||
import stat
|
import stat
|
||||||
import shutil
|
import shutil
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
import tempfile
|
import tempfile
|
||||||
import psutil
|
import psutil
|
||||||
import platform
|
import platform
|
||||||
@ -561,6 +564,51 @@ class BaseNode:
|
|||||||
log.warning(f"Cannot connect to node {self.name} console server: {e}")
|
log.warning(f"Cannot connect to node {self.name} console server: {e}")
|
||||||
return
|
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):
|
async def ws_forward(telnet_writer):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -571,6 +619,14 @@ class BaseNode:
|
|||||||
if "text" in msg and msg["text"]:
|
if "text" in msg and msg["text"]:
|
||||||
data = msg["text"].encode()
|
data = msg["text"].encode()
|
||||||
elif "bytes" in msg and msg["bytes"]:
|
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"]
|
data = msg["bytes"]
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -412,6 +412,17 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
|
|||||||
return False
|
return False
|
||||||
return True
|
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):
|
async def _on_naws(self, columns, rows):
|
||||||
if self._exec_id:
|
if self._exec_id:
|
||||||
try:
|
try:
|
||||||
@ -509,8 +520,9 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
|
|||||||
# (e.g. the IOS-XR pager) park at --More-- for clients
|
# (e.g. the IOS-XR pager) park at --More-- for clients
|
||||||
# that never negotiate NAWS (netmiko, bare telnet).
|
# that never negotiate NAWS (netmiko, bare telnet).
|
||||||
# Width 511 matches netmiko's 'terminal width 511'.
|
# Width 511 matches netmiko's 'terminal width 511'.
|
||||||
# Real NAWS clients resize to their own geometry right
|
# WebUI clients resize to their real geometry right after
|
||||||
# after connecting.
|
# connecting, via WS terminal-size control frames turned
|
||||||
|
# into NAWS by start_websocket_console.
|
||||||
await self._on_naws(511, 10000)
|
await self._on_naws(511, 10000)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user