netx/netx_api/ne_cli_hop.py
oliver 45de8144a3 Speed up WebCRT and record audited commands from the device echo.
Performance: event-driven stdout pump (no polling thread per socket, no 200ms
coalescing stall), keystrokes written immediately, blocking SSH reads, bounded
deque queue with batch drain, incremental UTF-8 decode, buffered session log,
non-blocking session teardown, shorter login/settle waits, xterm WebGL renderer
and idle-time direct writes. Zero-latency fake device: echo p50 11ms -> 2.3ms,
command RTT 219ms -> 20ms, bulk output 2.1 -> 8.3 MB/s.

Audit: track the device's own input row server-side (webcrt_cmd_audit) and
resolve each Enter from the echoed line feed, so Tab, backspace, cursor-key
edits, history recall, paste, Ctrl-C and pagers record what actually ran.
Password lines are redacted; pending Enters are flushed on close.

Hop: stop closing the hop session when the target prints the same prompt as the
hop (system-view); recognise the Huawei "closed by the remote host" message.
Generic NEs that show a network prompt now get the network key map.

Adds bench_webcrt.py, fake_ssh_device.py and e2e_webcrt_input_scenarios.py.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 07:34:30 +08:00

105 lines
3.5 KiB
Python

"""Vendor CLI hop return-to-proxy detection and session guard metadata."""
from __future__ import annotations
import re
from typing import Any
from netmiko import ConnectHandler
# Nested stelnet/telnet/ssh on vendor hops ends with messages like these; outer hop stays up.
_CLI_HOP_NESTED_END_RE = re.compile(
r"(?is)"
r"(?:^|\n)\s*(?:"
# Huawei VRP: "Info: The connection was closed by the remote host."
r"(?:info:\s*)?(?:the\s+)?connection\s+was\s+closed\s+by\s+(?:the\s+)?(?:foreign|remote)\s+host"
r"|connection\s+closed(?:\s+by\s+(?:foreign|remote)\s+host)?"
r"|closed\s+by\s+foreign\s+host"
r"|connection\s+to\s+\S+\s+closed"
r"|%\s*connection\s+closed(?:\s+by\s+(?:foreign|remote)\s+host)?"
r"|\[connection\s+to\s+[^\]]+closed\]"
r"|remote\s+host\s+closed\s+the\s+connection"
r")[^\n]*\s*(?:\n|$)"
)
# Last-line CLI prompts: <HW> [HW] Router# Router>
_CLI_PROMPT_LINE_RE = re.compile(
r"^(?:"
r"<[^>\r\n]{1,64}>|"
r"\[[^\]\r\n]{1,64}\]|"
r"[A-Za-z0-9][\w.\-:/]{0,62}[#>]"
r")\s*$"
)
def extract_cli_prompt_marker(text: str) -> str:
"""Return the last recognizable CLI prompt line from channel text."""
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
for line in reversed(s.split("\n")):
# Strip common ANSI CSI sequences so markers match live reader bytes.
cleaned = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", line).strip()
if cleaned and _CLI_PROMPT_LINE_RE.match(cleaned):
return cleaned
return ""
def cli_hop_nested_session_ended(text: str) -> bool:
"""True when nested jump (stelnet/telnet/ssh) reports connection closed."""
return bool(_CLI_HOP_NESTED_END_RE.search(str(text or "")))
def cli_hop_returned_to_proxy(text: str, hop_prompt: str) -> bool:
"""True when output ends on the hop prompt captured before the jump command."""
marker = str(hop_prompt or "").strip()
if not marker:
return False
last = extract_cli_prompt_marker(text)
return bool(last) and last == marker
def should_close_cli_hop_session(
recent: str,
hop_prompt: str = "",
*,
seen_other_prompt: bool = False,
) -> bool:
"""Policy: end WebCRT when nested target session drops back to the hop CLI.
Nested-close messages are matched only in a trailing window so a mid-session
``display log`` that reprints old "Connection closed" text does not trip.
Prompt-only return requires ``seen_other_prompt`` so identical default sysnames
(e.g. hop and target both ``<HUAWEI>``) do not close immediately after jump.
"""
text = str(recent or "")
if cli_hop_nested_session_ended(text[-800:]):
return True
if not seen_other_prompt:
return False
return cli_hop_returned_to_proxy(text, hop_prompt)
def get_cli_hop_guard(conn: ConnectHandler | None) -> dict[str, Any] | None:
"""Metadata attached by CLI hop connect; None when not a vendor CLI hop session."""
if conn is None:
return None
guard = getattr(conn, "_netx_cli_hop", None)
if not isinstance(guard, dict) or not guard.get("enabled"):
return None
return guard
def _attach_cli_hop_guard(
conn: ConnectHandler,
*,
hop_prompt: str,
hop_vendor: str,
hop_host: str,
) -> None:
conn._netx_cli_hop = { # type: ignore[attr-defined]
"enabled": True,
"hop_prompt": str(hop_prompt or "").strip(),
"hop_vendor": str(hop_vendor or "").strip().lower(),
"hop_host": str(hop_host or "").strip(),
}