mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
fix(webcrt): skip Netmiko terminal-length on interactive login.
Keep disable_paging for collection/MCP; WebCRT opens sessions with interactive=True so the device CLI is not pre-seeded. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9a72a9a099
commit
b8fa2c6725
3 changed files with 117 additions and 8 deletions
|
|
@ -242,6 +242,32 @@ def _netmiko_driver_class(device_type: str) -> type:
|
||||||
return cls
|
return cls
|
||||||
|
|
||||||
|
|
||||||
|
def _interactive_driver_class(base_cls: type) -> type:
|
||||||
|
"""Subclass that skips Netmiko auto terminal-length / terminal-width commands.
|
||||||
|
|
||||||
|
Collection and MCP exec need paging disabled; WebCRT must not inject those.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class _InteractiveSession(base_cls): # type: ignore[misc,valid-type]
|
||||||
|
def disable_paging(self, *args: Any, **kwargs: Any) -> str: # noqa: ANN401
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def set_terminal_width(self, *args: Any, **kwargs: Any) -> str: # noqa: ANN401
|
||||||
|
return ""
|
||||||
|
|
||||||
|
_InteractiveSession.__name__ = f"Interactive{getattr(base_cls, '__name__', 'Netmiko')}"
|
||||||
|
return _InteractiveSession
|
||||||
|
|
||||||
|
|
||||||
|
def _build_netmiko_connection(dev: dict[str, Any], *, interactive: bool = False) -> ConnectHandler:
|
||||||
|
"""Instantiate Netmiko from connect kwargs; optional interactive skips paging cmds."""
|
||||||
|
if not interactive:
|
||||||
|
return ConnectHandler(**dev)
|
||||||
|
device_type = str(dev.get("device_type") or "").strip()
|
||||||
|
base_cls = _netmiko_driver_class(device_type)
|
||||||
|
return _interactive_driver_class(base_cls)(**dev)
|
||||||
|
|
||||||
|
|
||||||
def _netmiko_over_ssh_client(
|
def _netmiko_over_ssh_client(
|
||||||
ssh_client: paramiko.SSHClient,
|
ssh_client: paramiko.SSHClient,
|
||||||
*,
|
*,
|
||||||
|
|
@ -253,11 +279,14 @@ def _netmiko_over_ssh_client(
|
||||||
enable_secret: str,
|
enable_secret: str,
|
||||||
session_timeout: int | None,
|
session_timeout: int | None,
|
||||||
session_log: Any = None,
|
session_log: Any = None,
|
||||||
|
interactive: bool = False,
|
||||||
) -> ConnectHandler:
|
) -> ConnectHandler:
|
||||||
"""Netmiko session over an already-authenticated SSH client (bastion protocol proxy)."""
|
"""Netmiko session over an already-authenticated SSH client (bastion protocol proxy)."""
|
||||||
base_cls = _netmiko_driver_class(device_type)
|
base_cls = _netmiko_driver_class(device_type)
|
||||||
|
if interactive:
|
||||||
|
base_cls = _interactive_driver_class(base_cls)
|
||||||
|
|
||||||
class _PreauthSession(base_cls):
|
class _PreauthSession(base_cls): # type: ignore[misc,valid-type]
|
||||||
def establish_connection(self, width: int = 511, height: int = 1000) -> None:
|
def establish_connection(self, width: int = 511, height: int = 1000) -> None:
|
||||||
from netmiko.channel import SSHChannel
|
from netmiko.channel import SSHChannel
|
||||||
|
|
||||||
|
|
@ -321,6 +350,7 @@ def _connect_direct(
|
||||||
*,
|
*,
|
||||||
session_timeout: int | None = None,
|
session_timeout: int | None = None,
|
||||||
session_log: Any = None,
|
session_log: Any = None,
|
||||||
|
interactive: bool = False,
|
||||||
) -> ConnectHandler:
|
) -> ConnectHandler:
|
||||||
device_type = normalize_netmiko_device_type(creds["device_type"], creds["protocol"])
|
device_type = normalize_netmiko_device_type(creds["device_type"], creds["protocol"])
|
||||||
dev = _base_connect_kwargs(
|
dev = _base_connect_kwargs(
|
||||||
|
|
@ -333,7 +363,7 @@ def _connect_direct(
|
||||||
session_timeout=session_timeout,
|
session_timeout=session_timeout,
|
||||||
session_log=session_log,
|
session_log=session_log,
|
||||||
)
|
)
|
||||||
return ConnectHandler(**dev)
|
return _build_netmiko_connection(dev, interactive=interactive)
|
||||||
|
|
||||||
|
|
||||||
def _read_channel(conn: ConnectHandler, wait: float = 0.5, max_loops: int = 40) -> str:
|
def _read_channel(conn: ConnectHandler, wait: float = 0.5, max_loops: int = 40) -> str:
|
||||||
|
|
@ -523,6 +553,7 @@ def _connect_via_cli_hop(
|
||||||
session_log: Any = None,
|
session_log: Any = None,
|
||||||
cols: int | None = None,
|
cols: int | None = None,
|
||||||
rows: int | None = None,
|
rows: int | None = None,
|
||||||
|
interactive: bool = False,
|
||||||
) -> ConnectHandler:
|
) -> ConnectHandler:
|
||||||
"""Login to ZTE/Huawei/Cisco hop NE, run CLI jump command, then target secondary auth."""
|
"""Login to ZTE/Huawei/Cisco hop NE, run CLI jump command, then target secondary auth."""
|
||||||
hop_host = str(creds.get("hop_host") or "").strip()
|
hop_host = str(creds.get("hop_host") or "").strip()
|
||||||
|
|
@ -543,7 +574,7 @@ def _connect_via_cli_hop(
|
||||||
session_timeout=session_timeout or 180,
|
session_timeout=session_timeout or 180,
|
||||||
session_log=session_log,
|
session_log=session_log,
|
||||||
)
|
)
|
||||||
conn = ConnectHandler(**hop_dev)
|
conn = _build_netmiko_connection(hop_dev, interactive=interactive)
|
||||||
try:
|
try:
|
||||||
# MUST resize before stelnet/telnet — nested session captures hop TTY size at start
|
# MUST resize before stelnet/telnet — nested session captures hop TTY size at start
|
||||||
# and often ignores later WINCH. Wrong width → mid-line edit redraw wraps in WebCRT.
|
# and often ignores later WINCH. Wrong width → mid-line edit redraw wraps in WebCRT.
|
||||||
|
|
@ -594,6 +625,7 @@ def _connect_via_bastion(
|
||||||
*,
|
*,
|
||||||
session_timeout: int | None = None,
|
session_timeout: int | None = None,
|
||||||
session_log: Any = None,
|
session_log: Any = None,
|
||||||
|
interactive: bool = False,
|
||||||
) -> ConnectHandler:
|
) -> ConnectHandler:
|
||||||
"""SSH to bastion with composite username; bastion proxies to target (protocol proxy)."""
|
"""SSH to bastion with composite username; bastion proxies to target (protocol proxy)."""
|
||||||
hop_host = str(creds.get("hop_host") or "").strip()
|
hop_host = str(creds.get("hop_host") or "").strip()
|
||||||
|
|
@ -626,6 +658,7 @@ def _connect_via_bastion(
|
||||||
enable_secret=str(creds.get("enable_secret") or ""),
|
enable_secret=str(creds.get("enable_secret") or ""),
|
||||||
session_timeout=session_timeout or 180,
|
session_timeout=session_timeout or 180,
|
||||||
session_log=session_log,
|
session_log=session_log,
|
||||||
|
interactive=interactive,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
if ssh_client is not None:
|
if ssh_client is not None:
|
||||||
|
|
@ -656,6 +689,7 @@ def _connect_via_linux_hop(
|
||||||
*,
|
*,
|
||||||
session_timeout: int | None = None,
|
session_timeout: int | None = None,
|
||||||
session_log: Any = None,
|
session_log: Any = None,
|
||||||
|
interactive: bool = False,
|
||||||
) -> ConnectHandler:
|
) -> ConnectHandler:
|
||||||
"""SSH to Linux bastion, then direct-tcpip tunnel to target (classic ProxyJump-style)."""
|
"""SSH to Linux bastion, then direct-tcpip tunnel to target (classic ProxyJump-style)."""
|
||||||
hop_host = str(creds.get("hop_host") or "").strip()
|
hop_host = str(creds.get("hop_host") or "").strip()
|
||||||
|
|
@ -711,7 +745,7 @@ def _connect_via_linux_hop(
|
||||||
session_log=session_log,
|
session_log=session_log,
|
||||||
)
|
)
|
||||||
dev["sock"] = channel
|
dev["sock"] = channel
|
||||||
conn = ConnectHandler(**dev)
|
conn = _build_netmiko_connection(dev, interactive=interactive)
|
||||||
conn._netx_jump_client = jump # type: ignore[attr-defined]
|
conn._netx_jump_client = jump # type: ignore[attr-defined]
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
@ -739,19 +773,40 @@ def open_netmiko_connection(
|
||||||
session_log: Any = None,
|
session_log: Any = None,
|
||||||
cols: int | None = None,
|
cols: int | None = None,
|
||||||
rows: int | None = None,
|
rows: int | None = None,
|
||||||
|
interactive: bool = False,
|
||||||
) -> ConnectHandler:
|
) -> ConnectHandler:
|
||||||
"""Open a Netmiko connection to the target NE (direct or via configured hop)."""
|
"""Open a Netmiko connection to the target NE (direct or via configured hop).
|
||||||
|
|
||||||
|
``interactive=True`` (WebCRT) skips Netmiko's automatic ``terminal length`` /
|
||||||
|
``terminal width`` (and vendor equivalents). Collection / MCP keep the default.
|
||||||
|
"""
|
||||||
if creds.get("hop_enabled"):
|
if creds.get("hop_enabled"):
|
||||||
vendor = _hop_vendor(creds)
|
vendor = _hop_vendor(creds)
|
||||||
if vendor == "linux":
|
if vendor == "linux":
|
||||||
return _connect_via_linux_hop(creds, session_timeout=session_timeout, session_log=session_log)
|
return _connect_via_linux_hop(
|
||||||
|
creds,
|
||||||
|
session_timeout=session_timeout,
|
||||||
|
session_log=session_log,
|
||||||
|
interactive=interactive,
|
||||||
|
)
|
||||||
if vendor == "bastion":
|
if vendor == "bastion":
|
||||||
return _connect_via_bastion(creds, session_timeout=session_timeout, session_log=session_log)
|
return _connect_via_bastion(
|
||||||
|
creds,
|
||||||
|
session_timeout=session_timeout,
|
||||||
|
session_log=session_log,
|
||||||
|
interactive=interactive,
|
||||||
|
)
|
||||||
return _connect_via_cli_hop(
|
return _connect_via_cli_hop(
|
||||||
creds,
|
creds,
|
||||||
session_timeout=session_timeout,
|
session_timeout=session_timeout,
|
||||||
session_log=session_log,
|
session_log=session_log,
|
||||||
cols=cols,
|
cols=cols,
|
||||||
rows=rows,
|
rows=rows,
|
||||||
|
interactive=interactive,
|
||||||
)
|
)
|
||||||
return _connect_direct(creds, session_timeout=session_timeout, session_log=session_log)
|
return _connect_direct(
|
||||||
|
creds,
|
||||||
|
session_timeout=session_timeout,
|
||||||
|
session_log=session_log,
|
||||||
|
interactive=interactive,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -598,6 +598,7 @@ def create_session(
|
||||||
session_log=log_buf,
|
session_log=log_buf,
|
||||||
cols=c,
|
cols=c,
|
||||||
rows=r,
|
rows=r,
|
||||||
|
interactive=True,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
partial = _session_log_text(log_buf).strip()
|
partial = _session_log_text(log_buf).strip()
|
||||||
|
|
|
||||||
53
tests/test_ne_session_interactive.py
Normal file
53
tests/test_ne_session_interactive.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from netx_api.ne_session_factory import (
|
||||||
|
_build_netmiko_connection,
|
||||||
|
_interactive_driver_class,
|
||||||
|
_netmiko_driver_class,
|
||||||
|
open_netmiko_connection,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InteractiveNetmikoTests(unittest.TestCase):
|
||||||
|
def test_interactive_driver_skips_paging_commands(self) -> None:
|
||||||
|
base = _netmiko_driver_class("cisco_ios")
|
||||||
|
cls = _interactive_driver_class(base)
|
||||||
|
self.assertEqual(cls.disable_paging(None), "")
|
||||||
|
self.assertEqual(cls.set_terminal_width(None), "")
|
||||||
|
|
||||||
|
def test_build_non_interactive_uses_connect_handler(self) -> None:
|
||||||
|
fake = MagicMock(name="conn")
|
||||||
|
with patch("netx_api.ne_session_factory.ConnectHandler", return_value=fake) as ch:
|
||||||
|
out = _build_netmiko_connection(
|
||||||
|
{
|
||||||
|
"device_type": "cisco_ios",
|
||||||
|
"host": "1.1.1.1",
|
||||||
|
"username": "u",
|
||||||
|
"password": "p",
|
||||||
|
},
|
||||||
|
interactive=False,
|
||||||
|
)
|
||||||
|
self.assertIs(out, fake)
|
||||||
|
ch.assert_called_once()
|
||||||
|
|
||||||
|
def test_open_webcrt_passes_interactive_flag(self) -> None:
|
||||||
|
creds = {
|
||||||
|
"device_type": "cisco_ios",
|
||||||
|
"protocol": "ssh",
|
||||||
|
"ip_address": "10.0.0.1",
|
||||||
|
"port": 22,
|
||||||
|
"username": "u",
|
||||||
|
"password": "p",
|
||||||
|
}
|
||||||
|
with patch("netx_api.ne_session_factory._connect_direct") as direct:
|
||||||
|
direct.return_value = MagicMock()
|
||||||
|
open_netmiko_connection(creds, interactive=True)
|
||||||
|
direct.assert_called_once()
|
||||||
|
self.assertTrue(direct.call_args.kwargs.get("interactive"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue