Skip to content

Commit fdddcf8

Browse files
authored
fix(device-utils): stabilize remote shell sessions (#32)
* fix(device-utils): stabilize remote shell sessions Improve EX/SRX remote shell handling by waiting for the initial shell output before sending the first keystrokes, buffering that output for the caller, and ignoring websocket close races during send. Add ShellSession.send_commands() as a convenience wrapper for sending multiple commands without manually appending line endings. Replace the sshkeyboard-based interactive shell with platform-specific terminal input loops, including POSIX raw-mode handling and Windows console key mapping. Remove the now-unused sshkeyboard dependency and update the lockfile. Update shell tests and README documentation for the new behavior. * bump version to 0.63.2 * fix: remove unnecessary import in shell session example * fix(docs): clarify interactive shell behavior and requirements * test: add unit test for posix input loop in interactive shell
1 parent 995770b commit fdddcf8

7 files changed

Lines changed: 738 additions & 423 deletions

File tree

README.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -845,27 +845,25 @@ All device utility functions return a `UtilResponse` object:
845845

846846
#### Interactive mode (human at the keyboard)
847847

848-
Takes over the terminal. Blocks until the connection closes or you press Ctrl+C:
848+
Takes over the terminal. Blocks until the connection closes (e.g. after typing `exit` on the device). On Linux/macOS the terminal runs in raw mode, so Ctrl+C is forwarded to the device; on Windows, Ctrl+C ends the session locally:
849849

850850
```python
851851
from mistapi.device_utils import ex
852852

853853
ex.interactiveShell(apisession, site_id, device_id)
854854
```
855855

856-
Requires the `sshkeyboard` package (installed automatically as a dependency).
856+
Requires an interactive terminal (TTY); raises `RuntimeError` if stdin is piped or redirected. No extra package is needed.
857857

858858
#### Programmatic mode
859859

860860
Use `createShellSession()` to get a `ShellSession` object for scripting:
861861

862862
```python
863863
from mistapi.device_utils import ex
864-
import time
865864

866865
with ex.createShellSession(apisession, site_id, device_id) as session:
867-
session.send_text("show version\r\n")
868-
time.sleep(3)
866+
session.send_commands(["configure","show | display set | no-more", "exit"])
869867
while True:
870868
data = session.recv(timeout=0.5)
871869
if data is None:
@@ -882,6 +880,7 @@ with ex.createShellSession(apisession, site_id, device_id) as session:
882880
| `connected` | `bool` | `True` if the WebSocket is currently connected. |
883881
| `send(data)` | `None` | Send raw bytes (keystrokes) to the device. |
884882
| `send_text(text)` | `None` | Send a text string to the device (auto-prefixed with `\x00`). |
883+
| `send_commands(commands)` | `None` | Send a list of commands to the device, each is automatically followed by a newline. |
885884
| `recv(timeout=0.1)` | `bytes \| None` | Receive output from the device. Returns `None` on timeout or if disconnected. |
886885
| `resize(rows, cols)` | `None` | Send a terminal resize message. |
887886

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "mistapi"
7-
version = "0.63.1"
7+
version = "0.63.2"
88
authors = [{ name = "Thomas Munzer", email = "tmunzer@juniper.net" }]
99
description = "Python package to simplify the Mist System APIs usage"
1010
keywords = ["Mist", "Juniper", "API"]
@@ -28,7 +28,6 @@ dependencies = [
2828
"hvac>=2.3.0",
2929
"keyring>=24.3.0",
3030
"websocket-client>=1.8.0",
31-
"sshkeyboard>=2.3.1",
3231
]
3332

3433
[project.urls]

src/mistapi/__version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
__version__ = "0.63.1"
1+
__version__ = "0.63.2"
22
__author__ = "Thomas Munzer <tmunzer@juniper.net>"

src/mistapi/api/v1/sites/sle.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
@deprecation.deprecated(
1919
deprecated_in="0.59.2",
2020
removed_in="0.65.0",
21-
current_version="0.63.0",
21+
current_version="0.63.2",
2222
details="function replaced with getSiteSleClassifierSummaryTrend",
2323
)
2424
def getSiteSleClassifierDetails(
@@ -764,7 +764,7 @@ def listSiteSleImpactedWirelessClients(
764764
@deprecation.deprecated(
765765
deprecated_in="0.59.2",
766766
removed_in="0.65.0",
767-
current_version="0.63.0",
767+
current_version="0.63.2",
768768
details="function replaced with getSiteSleSummaryTrend",
769769
)
770770
def getSiteSleSummary(

src/mistapi/device_utils/__tools/shell.py

Lines changed: 163 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@
2020

2121
import json
2222
import os
23+
import select
2324
import ssl
2425
import sys
2526
import threading
27+
import time
2628
from typing import TYPE_CHECKING
2729

2830
import websocket
@@ -45,7 +47,7 @@ class ShellSession:
4547
Programmatic::
4648
4749
session = create_shell_session(apisession, site_id, device_id)
48-
session.send_text("show version\\r\\n")
50+
session.send_commands(["show version"])
4951
while session.connected:
5052
data = session.recv()
5153
if data:
@@ -55,7 +57,7 @@ class ShellSession:
5557
Context manager::
5658
5759
with create_shell_session(apisession, site_id, device_id) as session:
58-
session.send_text("show interfaces terse\\r\\n")
60+
session.send_commands(["show interfaces terse"])
5961
import time; time.sleep(5)
6062
while True:
6163
data = session.recv()
@@ -92,6 +94,8 @@ def __init__(
9294
self._rows = rows
9395
self._cols = cols
9496
self._ws: websocket.WebSocket | None = None
97+
self._recv_buffer: list[bytes] = []
98+
self._shell_ready = False
9599

96100
# ------------------------------------------------------------------
97101
# Auth / SSL helpers (mirrors _MistWebsocket but avoids coupling)
@@ -165,6 +169,8 @@ def disconnect(self) -> None:
165169
"""Close the WebSocket connection."""
166170
ws = self._ws
167171
self._ws = None
172+
self._shell_ready = False
173+
self._recv_buffer.clear()
168174
if ws:
169175
try:
170176
ws.close()
@@ -184,19 +190,31 @@ def send(self, data: bytes) -> None:
184190
"""Send raw bytes (keystrokes) to the device shell."""
185191
ws = self._ws
186192
if ws and ws.connected:
187-
ws.send_binary(data)
193+
self._wait_for_shell_ready()
194+
if ws.connected:
195+
try:
196+
ws.send_binary(data)
197+
except websocket.WebSocketConnectionClosedException:
198+
pass
188199

189200
def send_text(self, text: str) -> None:
190201
"""Send a text string as binary data to the device shell."""
191202
self.send(f"\x00{text}".encode("utf-8"))
192203

204+
def send_commands(self, commands: list[str]) -> None:
205+
"""Send commands, adding a newline after each command."""
206+
text = "".join(command.rstrip("\n") + "\n" for command in commands)
207+
self.send_text(text)
208+
193209
def recv(self, timeout: float = 0.1) -> bytes | None:
194210
"""
195211
Receive raw bytes from the device shell.
196212
197213
Returns None if no data is available within the timeout, or if
198214
the connection is closed.
199215
"""
216+
if self._recv_buffer:
217+
return self._recv_buffer.pop(0)
200218
ws = self._ws
201219
if not ws or not ws.connected:
202220
return None
@@ -205,7 +223,9 @@ def recv(self, timeout: float = 0.1) -> bytes | None:
205223
ws.settimeout(timeout)
206224
data = ws.recv()
207225
if isinstance(data, str):
208-
return data.encode("utf-8")
226+
data = data.encode("utf-8")
227+
if data:
228+
self._shell_ready = True
209229
return data
210230
except websocket.WebSocketTimeoutException:
211231
return None
@@ -228,6 +248,51 @@ def recv(self, timeout: float = 0.1) -> bytes | None:
228248
exc,
229249
)
230250

251+
def _wait_for_shell_ready(self, timeout: float = 10.0) -> None:
252+
"""Wait for first shell output before sending keystrokes."""
253+
if self._shell_ready:
254+
return
255+
ws = self._ws
256+
if not ws or not ws.connected:
257+
return
258+
259+
old_timeout = ws.gettimeout()
260+
deadline = time.monotonic() + timeout
261+
try:
262+
while (
263+
time.monotonic() < deadline and ws.connected and not self._shell_ready
264+
):
265+
ws.settimeout(min(0.25, max(0.01, deadline - time.monotonic())))
266+
try:
267+
data = ws.recv()
268+
except websocket.WebSocketTimeoutException:
269+
continue
270+
except (
271+
websocket.WebSocketConnectionClosedException,
272+
ConnectionError,
273+
):
274+
return
275+
if isinstance(data, str):
276+
data = data.encode("utf-8")
277+
if data:
278+
self._recv_buffer.append(data)
279+
self._shell_ready = True
280+
return
281+
self._shell_ready = True
282+
finally:
283+
try:
284+
ws.settimeout(old_timeout)
285+
except (
286+
websocket.WebSocketConnectionClosedException,
287+
ConnectionError,
288+
OSError,
289+
) as exc:
290+
LOGGER.debug(
291+
"ShellSession._wait_for_shell_ready: failed to restore "
292+
"websocket timeout (socket may be closed): %s",
293+
exc,
294+
)
295+
231296
def resize(self, rows: int, cols: int) -> None:
232297
"""Send a terminal resize message to the device."""
233298
self._rows = rows
@@ -299,6 +364,80 @@ def create_shell_session(
299364
return session
300365

301366

367+
def _posix_input_loop(session: ShellSession) -> None:
368+
"""Forward raw keystrokes from a POSIX TTY until the session closes.
369+
370+
The terminal is put in raw mode, so control characters (including
371+
Ctrl+C) are forwarded to the device instead of being handled locally.
372+
"""
373+
import termios
374+
import tty
375+
376+
stdin_fd = sys.stdin.fileno()
377+
old_stdin_settings = termios.tcgetattr(stdin_fd)
378+
try:
379+
tty.setraw(stdin_fd)
380+
while session.connected:
381+
readable, _, _ = select.select([sys.stdin], [], [], 0.1)
382+
if not readable:
383+
continue
384+
data = os.read(stdin_fd, 1024)
385+
if not data:
386+
break
387+
session.send(b"\x00" + data)
388+
finally:
389+
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_stdin_settings)
390+
391+
392+
# Second half of the two-part console key codes returned by msvcrt.getwch()
393+
# (after a "\x00"/"\xe0" prefix), mapped to the ANSI sequences the device
394+
# pty expects.
395+
_WINDOWS_KEY_ESCAPES = {
396+
"H": "\x1b[A", # up
397+
"P": "\x1b[B", # down
398+
"M": "\x1b[C", # right
399+
"K": "\x1b[D", # left
400+
"G": "\x1b[H", # home
401+
"O": "\x1b[F", # end
402+
"S": "\x1b[3~", # delete
403+
"I": "\x1b[5~", # page up
404+
"Q": "\x1b[6~", # page down
405+
}
406+
407+
408+
def _windows_input_loop(session: ShellSession) -> None:
409+
"""Forward keystrokes from the Windows console until the session closes.
410+
411+
Unlike the POSIX raw-mode loop, Ctrl+C raises KeyboardInterrupt here
412+
(the console is not in raw mode), so it ends the session locally.
413+
"""
414+
import ctypes
415+
import msvcrt
416+
417+
# Legacy consoles need virtual terminal processing enabled to render
418+
# the ANSI sequences the device sends; Windows Terminal already has it.
419+
try:
420+
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
421+
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
422+
mode = ctypes.c_uint32()
423+
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
424+
kernel32.SetConsoleMode(handle, mode.value | 0x0004)
425+
except (OSError, AttributeError):
426+
pass
427+
428+
while session.connected:
429+
if not msvcrt.kbhit(): # type: ignore[attr-defined]
430+
time.sleep(0.05)
431+
continue
432+
ch = msvcrt.getwch() # type: ignore[attr-defined]
433+
if ch in ("\x00", "\xe0"):
434+
seq = _WINDOWS_KEY_ESCAPES.get(msvcrt.getwch()) # type: ignore[attr-defined]
435+
if seq is None:
436+
continue
437+
ch = seq
438+
session.send(b"\x00" + ch.encode("utf-8"))
439+
440+
302441
def interactive_shell(
303442
apisession: "APISession",
304443
site_id: str,
@@ -308,8 +447,10 @@ def interactive_shell(
308447
Launch an interactive SSH shell session to a device.
309448
310449
Takes over the terminal: captures keystrokes, sends them to the device,
311-
and displays output. Blocks until the connection closes or the user
312-
presses Ctrl+C.
450+
and displays output. Blocks until the connection closes (e.g. after
451+
typing ``exit`` on the device). On POSIX systems the terminal runs in
452+
raw mode, so Ctrl+C is forwarded to the device rather than ending the
453+
session; on Windows, Ctrl+C ends the session locally.
313454
314455
PARAMS
315456
-----------
@@ -319,8 +460,19 @@ def interactive_shell(
319460
UUID of the site where the device is located.
320461
device_id : str
321462
UUID of the device to connect to.
463+
464+
RAISES
465+
-----------
466+
RuntimeError
467+
If stdin is not an interactive terminal (TTY). Use ShellSession
468+
for programmatic access.
322469
"""
323-
from sshkeyboard import listen_keyboard
470+
if not sys.stdin.isatty():
471+
raise RuntimeError(
472+
"interactive_shell requires an interactive terminal (stdin is "
473+
"not a TTY); use ShellSession/create_shell_session for "
474+
"programmatic access"
475+
)
324476

325477
try:
326478
cols, rows = os.get_terminal_size()
@@ -337,40 +489,14 @@ def _reader():
337489
sys.stdout.buffer.write(data)
338490
sys.stdout.buffer.flush()
339491

340-
def _on_key_press(key: str) -> None:
341-
"""Handle a key press event from sshkeyboard."""
342-
if not session.connected:
343-
return
344-
if key == "enter":
345-
k = "\r\n"
346-
elif key == "space":
347-
k = " "
348-
elif key == "tab":
349-
k = "\t"
350-
elif key == "up":
351-
k = "\x1b[A"
352-
elif key == "right":
353-
k = "\x1b[C"
354-
elif key == "down":
355-
k = "\x1b[B"
356-
elif key == "left":
357-
k = "\x1b[D"
358-
elif key == "backspace":
359-
k = "\x7f"
360-
else:
361-
k = key
362-
session.send(f"\x00{k}".encode("utf-8"))
363-
364492
reader_thread = threading.Thread(target=_reader, daemon=True)
365493
reader_thread.start()
366494

367495
try:
368-
listen_keyboard(
369-
on_press=_on_key_press,
370-
delay_second_char=0,
371-
delay_other_chars=0,
372-
lower=False,
373-
)
496+
if os.name == "nt":
497+
_windows_input_loop(session)
498+
else:
499+
_posix_input_loop(session)
374500
except KeyboardInterrupt:
375501
pass
376502
finally:

0 commit comments

Comments
 (0)