mirror of
https://github.com/hansjone/netx.git
synced 2026-10-20 06:03:17 +08:00
Stream WebCRT login live and unblock Huawei hop interactive connect.
Skip Huawei special_login_handler under interactive WebCRT, harden stelnet host-key auth, and echo hop/login progress into the terminal during connect. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6901a7c019
commit
6ed08e24de
8 changed files with 386 additions and 67 deletions
|
|
@ -1,8 +1,10 @@
|
||||||
"""Netmiko connect paths: direct, vendor CLI hop, bastion, and Linux jump."""
|
"""Netmiko connect paths: direct, vendor CLI hop, bastion, and Linux jump."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -141,7 +143,10 @@ def _interactive_driver_class(base_cls: type) -> type:
|
||||||
"""Subclass for WebCRT: raw interactive PTY after transport auth (SecureCRT-like).
|
"""Subclass for WebCRT: raw interactive PTY after transport auth (SecureCRT-like).
|
||||||
|
|
||||||
Skips Netmiko session prep (prompt discovery, terminal length/width, force RETURN)
|
Skips Netmiko session prep (prompt discovery, terminal length/width, force RETURN)
|
||||||
so the channel is left for the user — not consumed by library automation.
|
and Huawei ``special_login_handler`` (read_until prompt / password-change). Those
|
||||||
|
waits are why WebCRT stuck on ``waiting_prompt`` while connectivity probes succeed:
|
||||||
|
collection still runs full Netmiko login; WebCRT must leave the PTY for live echo
|
||||||
|
and hop secondary auth instead.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class _InteractiveSession(base_cls): # type: ignore[misc,valid-type]
|
class _InteractiveSession(base_cls): # type: ignore[misc,valid-type]
|
||||||
|
|
@ -154,6 +159,9 @@ def _interactive_driver_class(base_cls: type) -> type:
|
||||||
def session_preparation(self) -> None:
|
def session_preparation(self) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def special_login_handler(self, delay_factor: float = 1.0) -> None: # noqa: ARG002
|
||||||
|
return None
|
||||||
|
|
||||||
def _try_session_preparation(self, force_data: bool = True) -> None: # noqa: FBT001, FBT002
|
def _try_session_preparation(self, force_data: bool = True) -> None: # noqa: FBT001, FBT002
|
||||||
del force_data
|
del force_data
|
||||||
try:
|
try:
|
||||||
|
|
@ -166,6 +174,44 @@ def _interactive_driver_class(base_cls: type) -> type:
|
||||||
return _InteractiveSession
|
return _InteractiveSession
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_progress(progress_cb: Any, text: str) -> None:
|
||||||
|
"""Best-effort live login transcript callback (WebCRT connect echo)."""
|
||||||
|
if not progress_cb or not text:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
progress_cb(str(text))
|
||||||
|
except Exception:
|
||||||
|
_log.debug("connect progress_cb failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
class _ProgressSessionLog:
|
||||||
|
"""Tee Netmiko ``session_log`` writes into a progress callback + BytesIO."""
|
||||||
|
|
||||||
|
def __init__(self, progress_cb: Any = None) -> None:
|
||||||
|
self._buf = io.BytesIO()
|
||||||
|
self._progress_cb = progress_cb
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def write(self, data: Any) -> int:
|
||||||
|
if isinstance(data, bytes):
|
||||||
|
raw = data
|
||||||
|
text = data.decode("utf-8", errors="replace")
|
||||||
|
else:
|
||||||
|
text = str(data or "")
|
||||||
|
raw = text.encode("utf-8", errors="replace")
|
||||||
|
with self._lock:
|
||||||
|
self._buf.write(raw)
|
||||||
|
_emit_progress(self._progress_cb, text)
|
||||||
|
return len(raw)
|
||||||
|
|
||||||
|
def flush(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def getvalue(self) -> bytes:
|
||||||
|
with self._lock:
|
||||||
|
return self._buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def _cisco_ios_collection_driver_class(base_cls: type) -> type:
|
def _cisco_ios_collection_driver_class(base_cls: type) -> type:
|
||||||
"""Cisco IOSv-friendly session prep: avoid cmd_verify on terminal width/length."""
|
"""Cisco IOSv-friendly session prep: avoid cmd_verify on terminal width/length."""
|
||||||
|
|
||||||
|
|
@ -280,6 +326,9 @@ def _netmiko_over_ssh_client(
|
||||||
if chan_transport is not None:
|
if chan_transport is not None:
|
||||||
chan_transport.set_keepalive(self.keepalive)
|
chan_transport.set_keepalive(self.keepalive)
|
||||||
self.channel = SSHChannel(conn=self.remote_conn, encoding=self.encoding)
|
self.channel = SSHChannel(conn=self.remote_conn, encoding=self.encoding)
|
||||||
|
# Interactive WebCRT: do not block on vendor special_login_handler
|
||||||
|
# (Huawei read_until ``[>]`` hangs on bastion/JumpServer banners).
|
||||||
|
if not interactive:
|
||||||
self.special_login_handler()
|
self.special_login_handler()
|
||||||
|
|
||||||
dev = _base_connect_kwargs(
|
dev = _base_connect_kwargs(
|
||||||
|
|
@ -356,14 +405,29 @@ def _connect_direct(
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
|
if wait > 0:
|
||||||
time.sleep(wait)
|
time.sleep(wait)
|
||||||
chunks: list[str] = []
|
chunks: list[str] = []
|
||||||
for _ in range(max_loops):
|
for _ in range(max_loops):
|
||||||
|
try:
|
||||||
part = conn.read_channel()
|
part = conn.read_channel()
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
if part is None or part is False:
|
||||||
|
break
|
||||||
|
# MagicMock truthy-but-empty: stop when unit tests leave read_channel unconfigured.
|
||||||
|
if not isinstance(part, (str, bytes, bytearray)):
|
||||||
|
text = str(part)
|
||||||
|
# unittest.mock default str looks like "<MagicMock name=...>"
|
||||||
|
if text.startswith("<MagicMock") or text.startswith("<Mock"):
|
||||||
|
break
|
||||||
|
part = text
|
||||||
|
elif isinstance(part, (bytes, bytearray)):
|
||||||
|
part = bytes(part).decode("utf-8", errors="replace")
|
||||||
if not part:
|
if not part:
|
||||||
break
|
break
|
||||||
chunks.append(part)
|
chunks.append(part)
|
||||||
time.sleep(0.2)
|
time.sleep(0.05 if wait <= 0.15 else 0.15)
|
||||||
return "".join(chunks)
|
return "".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -374,11 +438,21 @@ def _send_line(conn: ConnectHandler, line: str) -> None:
|
||||||
conn.write_channel(text)
|
conn.write_channel(text)
|
||||||
|
|
||||||
|
|
||||||
def _auth_prompt_tail(text: str) -> str:
|
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\].*?\x07|\x1b.")
|
||||||
"""Last non-empty line of an auth transcript (prompt detection)."""
|
|
||||||
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
|
|
||||||
lines = [ln.strip() for ln in s.split("\n") if ln.strip()]
|
def _strip_ansi(text: str) -> str:
|
||||||
return lines[-1] if lines else ""
|
return _ANSI_RE.sub("", str(text or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_prompt_tail(text: str, *, lines: int = 1) -> str:
|
||||||
|
"""Last non-empty line(s) of an auth transcript (prompt detection)."""
|
||||||
|
s = _strip_ansi(text).replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
parts = [ln.strip() for ln in s.split("\n") if ln.strip()]
|
||||||
|
if not parts:
|
||||||
|
return ""
|
||||||
|
n = max(1, int(lines))
|
||||||
|
return "\n".join(parts[-n:])
|
||||||
|
|
||||||
|
|
||||||
def _prompt_needs_auth(text: str) -> tuple[bool, bool]:
|
def _prompt_needs_auth(text: str) -> tuple[bool, bool]:
|
||||||
|
|
@ -386,11 +460,15 @@ def _prompt_needs_auth(text: str) -> tuple[bool, bool]:
|
||||||
tail = _auth_prompt_tail(text).lower()
|
tail = _auth_prompt_tail(text).lower()
|
||||||
if not tail:
|
if not tail:
|
||||||
return False, False
|
return False, False
|
||||||
# Prefer last-line match so earlier echoed prompts in ``acc`` do not stick forever.
|
|
||||||
need_user = bool(
|
need_user = bool(
|
||||||
re.search(r"(?:please\s+input\s+the\s+)?(?:user\s*name|username|login)\s*[:>]\s*$", tail)
|
re.search(
|
||||||
|
r"(?:please\s+input\s+the\s+)?(?:user\s*name|username|login|用户名|用户)\s*[:>]\s*$",
|
||||||
|
tail,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
need_pass = bool(
|
||||||
|
re.search(r"(?:enter\s+)?(?:password|密码)\s*[:>]\s*$", tail)
|
||||||
)
|
)
|
||||||
need_pass = bool(re.search(r"(?:enter\s+)?password\s*[:>]\s*$", tail))
|
|
||||||
return need_user, need_pass
|
return need_user, need_pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -400,22 +478,40 @@ def _prompt_needs_host_key_confirm(text: str) -> tuple[bool, bool]:
|
||||||
Returns ``(continue_access, save_public_key)`` for:
|
Returns ``(continue_access, save_public_key)`` for:
|
||||||
- ``The server is not authenticated. Continue to access it? [Y/N]:`` → Y
|
- ``The server is not authenticated. Continue to access it? [Y/N]:`` → Y
|
||||||
- ``Save the server's public key? [Y/N]:`` → N
|
- ``Save the server's public key? [Y/N]:`` → N
|
||||||
|
|
||||||
|
Also handles wrapped prompts where ``[Y/N]:`` is alone on the last line.
|
||||||
"""
|
"""
|
||||||
tail = _auth_prompt_tail(text)
|
block = _auth_prompt_tail(text, lines=3)
|
||||||
if not tail:
|
if not block:
|
||||||
return False, False
|
return False, False
|
||||||
low = tail.lower()
|
low = block.lower()
|
||||||
# Do not treat password-change ``Change now? [Y/N]:`` as host-key trust.
|
# Do not treat password-change ``Change now? [Y/N]:`` as host-key trust.
|
||||||
if re.search(r"(?:change\s*now|please\s*choose|password\s+needs\s+to\s+be\s+changed)", low):
|
if re.search(
|
||||||
|
r"(?:change\s*now|please\s*choose|password\s+needs\s+to\s+be\s+changed|修改密码|是否现在修改)",
|
||||||
|
low,
|
||||||
|
):
|
||||||
|
return False, False
|
||||||
|
has_yn = bool(re.search(r"\[Y/N\]\s*:\s*$", block, flags=re.I | re.M))
|
||||||
|
if not has_yn:
|
||||||
return False, False
|
return False, False
|
||||||
continue_access = bool(
|
continue_access = bool(
|
||||||
re.search(r"(?:not\s+authenticated|continue\s+to\s+access)", low)
|
re.search(
|
||||||
and re.search(r"\[Y/N\]\s*:\s*$", tail, flags=re.I)
|
r"(?:not\s+authenticated|continue\s+to\s+access|未认证|继续访问|是否继续)",
|
||||||
|
low,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
save_key = bool(
|
save_key = bool(
|
||||||
re.search(r"save\s+the\s+server'?s?\s+public\s+key", low)
|
re.search(
|
||||||
and re.search(r"\[Y/N\]\s*:\s*$", tail, flags=re.I)
|
r"(?:save\s+the\s+server'?s?\s+public\s+key|保存.*公钥|是否保存.*(?:公钥|密钥))",
|
||||||
|
low,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
# Bare ``[Y/N]:`` after a continue question without the word "public key".
|
||||||
|
if continue_access and save_key:
|
||||||
|
# Prefer the more specific save-key wording when both match the same block.
|
||||||
|
if re.search(r"(?:save\s+the\s+server|保存.*公钥|是否保存)", low):
|
||||||
|
return False, True
|
||||||
|
return True, False
|
||||||
return continue_access, save_key
|
return continue_access, save_key
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -433,21 +529,29 @@ def _looks_like_target_cli_prompt(text: str) -> bool:
|
||||||
return bool(re.search(r"(?:[>#\]])\s*$", tail)) or bool(re.search(r"^<[^>\r\n]+>\s*$", tail))
|
return bool(re.search(r"(?:[>#\]])\s*$", tail)) or bool(re.search(r"^<[^>\r\n]+>\s*$", tail))
|
||||||
|
|
||||||
|
|
||||||
def _interactive_target_auth(conn: ConnectHandler, username: str, password: str) -> None:
|
def _interactive_target_auth(
|
||||||
|
conn: ConnectHandler,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
*,
|
||||||
|
progress_cb: Any = None,
|
||||||
|
) -> None:
|
||||||
"""Respond to username/password (and Huawei stelnet host-key) prompts after hop command."""
|
"""Respond to username/password (and Huawei stelnet host-key) prompts after hop command."""
|
||||||
from .ne_cli_errors import find_auth_failure_snippet
|
from .ne_cli_errors import find_auth_failure_snippet
|
||||||
|
|
||||||
# Hop stelnet can show Trying/Connected + two [Y/N] before password; keep budget generous.
|
# Hop stelnet can show Trying/Connected + two [Y/N] before password; keep budget generous.
|
||||||
deadline = time.time() + max(45, int(settings.ne_connect_timeout_sec or 30) + 15)
|
deadline = time.time() + max(60, int(settings.ne_connect_timeout_sec or 30) + 30)
|
||||||
sent_user = False
|
sent_user = False
|
||||||
sent_pass = False
|
sent_pass = False
|
||||||
answered_continue = False
|
answered_continue = False
|
||||||
answered_save_key = False
|
answered_save_key = False
|
||||||
|
empty_after_pass = 0
|
||||||
acc = ""
|
acc = ""
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
buf = _read_channel(conn, wait=0.3, max_loops=8)
|
buf = _read_channel(conn, wait=0.12, max_loops=12)
|
||||||
if buf:
|
if buf:
|
||||||
acc += buf
|
acc += buf
|
||||||
|
_emit_progress(progress_cb, buf)
|
||||||
denied = find_auth_failure_snippet(acc)
|
denied = find_auth_failure_snippet(acc)
|
||||||
if denied:
|
if denied:
|
||||||
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
|
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
|
||||||
|
|
@ -455,20 +559,25 @@ def _interactive_target_auth(conn: ConnectHandler, username: str, password: str)
|
||||||
# Match against accumulated tail so prompts split across reads are still seen.
|
# Match against accumulated tail so prompts split across reads are still seen.
|
||||||
continue_access, save_key = _prompt_needs_host_key_confirm(acc)
|
continue_access, save_key = _prompt_needs_host_key_confirm(acc)
|
||||||
if continue_access and not answered_continue:
|
if continue_access and not answered_continue:
|
||||||
|
_emit_progress(progress_cb, "\r\n[netx] host-key continue → Y\r\n")
|
||||||
_send_line(conn, "Y")
|
_send_line(conn, "Y")
|
||||||
answered_continue = True
|
answered_continue = True
|
||||||
continue
|
continue
|
||||||
if save_key and not answered_save_key:
|
if save_key and not answered_save_key:
|
||||||
|
_emit_progress(progress_cb, "\r\n[netx] save public key → N\r\n")
|
||||||
_send_line(conn, "N")
|
_send_line(conn, "N")
|
||||||
answered_save_key = True
|
answered_save_key = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
need_user, need_pass = _prompt_needs_auth(acc)
|
need_user, need_pass = _prompt_needs_auth(acc)
|
||||||
if need_pass and not sent_pass:
|
if need_pass and not sent_pass:
|
||||||
|
_emit_progress(progress_cb, "\r\n[netx] sending password\r\n")
|
||||||
_send_line(conn, password)
|
_send_line(conn, password)
|
||||||
sent_pass = True
|
sent_pass = True
|
||||||
|
empty_after_pass = 0
|
||||||
continue
|
continue
|
||||||
if need_user and not sent_user:
|
if need_user and not sent_user:
|
||||||
|
_emit_progress(progress_cb, f"\r\n[netx] sending username ({username})\r\n")
|
||||||
_send_line(conn, username)
|
_send_line(conn, username)
|
||||||
sent_user = True
|
sent_user = True
|
||||||
continue
|
continue
|
||||||
|
|
@ -477,15 +586,21 @@ def _interactive_target_auth(conn: ConnectHandler, username: str, password: str)
|
||||||
denied = find_auth_failure_snippet(acc)
|
denied = find_auth_failure_snippet(acc)
|
||||||
if denied:
|
if denied:
|
||||||
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
|
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
|
||||||
# Prefer a real CLI prompt (Huawei last-login banner may precede it).
|
if _looks_like_target_cli_prompt(acc):
|
||||||
if _looks_like_target_cli_prompt(acc) or not buf.strip():
|
|
||||||
return
|
return
|
||||||
# Banner mid-stream after password — keep reading briefly within deadline.
|
# Do not treat a single empty read right after password as success —
|
||||||
time.sleep(0.2)
|
# Huawei still prints last-login banner / prompt.
|
||||||
|
if not buf.strip():
|
||||||
|
empty_after_pass += 1
|
||||||
|
if empty_after_pass >= 4:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
empty_after_pass = 0
|
||||||
|
time.sleep(0.15)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not buf.strip():
|
if not buf.strip():
|
||||||
time.sleep(0.3)
|
time.sleep(0.2)
|
||||||
continue
|
continue
|
||||||
# Huawei stelnet often reprints the hop ``[sysname]`` after host-key trust and
|
# Huawei stelnet often reprints the hop ``[sysname]`` after host-key trust and
|
||||||
# *before* ``Enter password:``. Do not treat that as target login success.
|
# *before* ``Enter password:``. Do not treat that as target login success.
|
||||||
|
|
@ -506,12 +621,14 @@ def _interactive_target_auth(conn: ConnectHandler, username: str, password: str)
|
||||||
if denied:
|
if denied:
|
||||||
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
|
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
|
||||||
return
|
return
|
||||||
time.sleep(0.3)
|
time.sleep(0.15)
|
||||||
denied = find_auth_failure_snippet(acc)
|
denied = find_auth_failure_snippet(acc)
|
||||||
if denied:
|
if denied:
|
||||||
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
|
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
|
||||||
if not sent_pass:
|
if not sent_pass:
|
||||||
raise TimeoutError("target_auth_timeout")
|
raise TimeoutError("target_auth_timeout")
|
||||||
|
# Password was sent but no clear prompt — hand channel to caller anyway.
|
||||||
|
_emit_progress(progress_cb, "\r\n[netx] auth settle timeout; handing session to terminal\r\n")
|
||||||
|
|
||||||
|
|
||||||
def _hop_netmiko_device_type(vendor: str, hop_protocol: str) -> str:
|
def _hop_netmiko_device_type(vendor: str, hop_protocol: str) -> str:
|
||||||
|
|
@ -553,6 +670,7 @@ def _connect_via_cli_hop(
|
||||||
rows: int | None = None,
|
rows: int | None = None,
|
||||||
interactive: bool = False,
|
interactive: bool = False,
|
||||||
keepalive: int | None = None,
|
keepalive: int | None = None,
|
||||||
|
progress_cb: Any = None,
|
||||||
) -> 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()
|
||||||
|
|
@ -574,12 +692,15 @@ def _connect_via_cli_hop(
|
||||||
session_log=session_log,
|
session_log=session_log,
|
||||||
keepalive=keepalive,
|
keepalive=keepalive,
|
||||||
)
|
)
|
||||||
|
_emit_progress(progress_cb, f"\r\n[netx] connecting hop {_hop_vendor(creds)} {hop_host}…\r\n")
|
||||||
conn = _build_netmiko_connection(hop_dev, interactive=interactive)
|
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.
|
||||||
_resize_pty(conn, cols, rows)
|
_resize_pty(conn, cols, rows)
|
||||||
pre = _read_channel(conn, wait=0.5)
|
pre = _read_channel(conn, wait=0.35)
|
||||||
|
if pre:
|
||||||
|
_emit_progress(progress_cb, pre)
|
||||||
hop_prompt = extract_cli_prompt_marker(pre)
|
hop_prompt = extract_cli_prompt_marker(pre)
|
||||||
if not hop_prompt:
|
if not hop_prompt:
|
||||||
# Nudge hop CLI once so the prompt is visible for later return-to-proxy detection.
|
# Nudge hop CLI once so the prompt is visible for later return-to-proxy detection.
|
||||||
|
|
@ -587,21 +708,31 @@ def _connect_via_cli_hop(
|
||||||
conn.write_channel(getattr(conn, "RETURN", None) or "\n")
|
conn.write_channel(getattr(conn, "RETURN", None) or "\n")
|
||||||
except Exception:
|
except Exception:
|
||||||
_send_line(conn, "")
|
_send_line(conn, "")
|
||||||
pre = pre + _read_channel(conn, wait=0.35, max_loops=10)
|
more = _read_channel(conn, wait=0.25, max_loops=12)
|
||||||
|
if more:
|
||||||
|
_emit_progress(progress_cb, more)
|
||||||
|
pre = pre + more
|
||||||
hop_prompt = extract_cli_prompt_marker(pre)
|
hop_prompt = extract_cli_prompt_marker(pre)
|
||||||
# WebCRT skips hop session_preparation; wait a bit longer for a settled CLI
|
# WebCRT skips hop session_preparation; wait a bit longer for a settled CLI
|
||||||
# before stelnet/telnet so the jump command is not typed into a half-ready PTY.
|
# before stelnet/telnet so the jump command is not typed into a half-ready PTY.
|
||||||
if interactive and not hop_prompt:
|
if interactive and not hop_prompt:
|
||||||
for _ in range(4):
|
for _ in range(6):
|
||||||
more = _read_channel(conn, wait=0.4, max_loops=8)
|
more = _read_channel(conn, wait=0.3, max_loops=10)
|
||||||
if more:
|
if more:
|
||||||
|
_emit_progress(progress_cb, more)
|
||||||
pre += more
|
pre += more
|
||||||
hop_prompt = extract_cli_prompt_marker(pre)
|
hop_prompt = extract_cli_prompt_marker(pre)
|
||||||
if hop_prompt:
|
if hop_prompt:
|
||||||
break
|
break
|
||||||
hop_cmd = render_hop_command(str(creds.get("hop_command_template") or ""), creds)
|
hop_cmd = render_hop_command(str(creds.get("hop_command_template") or ""), creds)
|
||||||
|
_emit_progress(progress_cb, f"\r\n[netx] hop jump: {hop_cmd}\r\n")
|
||||||
_send_line(conn, hop_cmd)
|
_send_line(conn, hop_cmd)
|
||||||
_interactive_target_auth(conn, str(creds["username"]), str(creds["password"]))
|
_interactive_target_auth(
|
||||||
|
conn,
|
||||||
|
str(creds["username"]),
|
||||||
|
str(creds["password"]),
|
||||||
|
progress_cb=progress_cb,
|
||||||
|
)
|
||||||
_attach_cli_hop_guard(
|
_attach_cli_hop_guard(
|
||||||
conn,
|
conn,
|
||||||
hop_prompt=hop_prompt,
|
hop_prompt=hop_prompt,
|
||||||
|
|
@ -630,6 +761,35 @@ def _connect_via_cli_hop(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_secondary_target_auth(
|
||||||
|
conn: ConnectHandler,
|
||||||
|
creds: dict[str, Any],
|
||||||
|
*,
|
||||||
|
progress_cb: Any = None,
|
||||||
|
force: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Run interactive target auth when the PTY still shows login / host-key prompts."""
|
||||||
|
peek = _read_channel(conn, wait=0.45, max_loops=14)
|
||||||
|
if peek:
|
||||||
|
_emit_progress(progress_cb, peek)
|
||||||
|
need_user, need_pass = _prompt_needs_auth(peek)
|
||||||
|
cont, save = _prompt_needs_host_key_confirm(peek)
|
||||||
|
target_user = str(creds.get("username") or "").strip()
|
||||||
|
target_pass = str(creds.get("password") or "")
|
||||||
|
if not force and not (need_user or need_pass or cont or save):
|
||||||
|
return
|
||||||
|
if not target_pass and not force:
|
||||||
|
_emit_progress(
|
||||||
|
progress_cb,
|
||||||
|
"\r\n[netx] login prompt visible but target password empty; leaving for terminal\r\n",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not target_user and need_user:
|
||||||
|
_emit_progress(progress_cb, "\r\n[netx] username prompt but target username empty\r\n")
|
||||||
|
return
|
||||||
|
_interactive_target_auth(conn, target_user, target_pass, progress_cb=progress_cb)
|
||||||
|
|
||||||
|
|
||||||
def _connect_via_bastion(
|
def _connect_via_bastion(
|
||||||
creds: dict[str, Any],
|
creds: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
|
|
@ -637,6 +797,7 @@ def _connect_via_bastion(
|
||||||
session_log: Any = None,
|
session_log: Any = None,
|
||||||
interactive: bool = False,
|
interactive: bool = False,
|
||||||
keepalive: int | None = None,
|
keepalive: int | None = None,
|
||||||
|
progress_cb: Any = None,
|
||||||
) -> 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, hop_user = expand_bastion_hop_fields(
|
hop_host, hop_user = expand_bastion_hop_fields(
|
||||||
|
|
@ -654,6 +815,10 @@ def _connect_via_bastion(
|
||||||
device_type = normalize_netmiko_device_type(creds["device_type"], creds["protocol"])
|
device_type = normalize_netmiko_device_type(creds["device_type"], creds["protocol"])
|
||||||
hop_port = int(creds.get("hop_port") or 22)
|
hop_port = int(creds.get("hop_port") or 22)
|
||||||
timeout = int(settings.ne_connect_timeout_sec or 30)
|
timeout = int(settings.ne_connect_timeout_sec or 30)
|
||||||
|
_emit_progress(
|
||||||
|
progress_cb,
|
||||||
|
f"\r\n[netx] bastion SSH {hop_user}@{hop_host} as {ssh_username!r}…\r\n",
|
||||||
|
)
|
||||||
ssh_client = None
|
ssh_client = None
|
||||||
try:
|
try:
|
||||||
ssh_client = _bastion_ssh_connect(
|
ssh_client = _bastion_ssh_connect(
|
||||||
|
|
@ -663,6 +828,7 @@ def _connect_via_bastion(
|
||||||
password=hop_pass,
|
password=hop_pass,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
_emit_progress(progress_cb, "\r\n[netx] bastion transport OK; opening shell…\r\n")
|
||||||
conn = _netmiko_over_ssh_client(
|
conn = _netmiko_over_ssh_client(
|
||||||
ssh_client,
|
ssh_client,
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
|
|
@ -685,12 +851,20 @@ def _connect_via_bastion(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
auth_mode = str(creds.get("hop_target_auth_mode") or "bastion_managed").strip().lower()
|
auth_mode = str(creds.get("hop_target_auth_mode") or "bastion_managed").strip().lower()
|
||||||
|
try:
|
||||||
if auth_mode == "manual":
|
if auth_mode == "manual":
|
||||||
target_pass = str(creds.get("password") or "")
|
target_pass = str(creds.get("password") or "")
|
||||||
if target_pass:
|
if target_pass:
|
||||||
try:
|
_maybe_secondary_target_auth(conn, creds, progress_cb=progress_cb, force=True)
|
||||||
_read_channel(conn, wait=0.5)
|
else:
|
||||||
_interactive_target_auth(conn, str(creds["username"]), target_pass)
|
# Still drain banner so WebCRT live echo shows what the proxy printed.
|
||||||
|
peek = _read_channel(conn, wait=0.4, max_loops=12)
|
||||||
|
if peek:
|
||||||
|
_emit_progress(progress_cb, peek)
|
||||||
|
else:
|
||||||
|
# bastion_managed: normally no secondary auth, but if the proxy still presents
|
||||||
|
# Username/Password or Huawei host-key prompts, answer them when creds exist.
|
||||||
|
_maybe_secondary_target_auth(conn, creds, progress_cb=progress_cb, force=False)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
conn.disconnect()
|
conn.disconnect()
|
||||||
|
|
@ -793,22 +967,54 @@ def open_netmiko_connection(
|
||||||
rows: int | None = None,
|
rows: int | None = None,
|
||||||
interactive: bool = False,
|
interactive: bool = False,
|
||||||
keepalive: int | None = None,
|
keepalive: int | None = None,
|
||||||
|
progress_cb: Any = None,
|
||||||
) -> 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`` /
|
``interactive=True`` (WebCRT) skips Netmiko's automatic ``terminal length`` /
|
||||||
``terminal width`` (and vendor equivalents). Collection / MCP keep the default.
|
``terminal width`` (and vendor equivalents). Collection / MCP keep the default.
|
||||||
|
|
||||||
|
``progress_cb`` receives live login transcript chunks (WebCRT connect echo).
|
||||||
"""
|
"""
|
||||||
ka = keepalive
|
ka = keepalive
|
||||||
if ka is None and interactive:
|
if ka is None and interactive:
|
||||||
ka = int(getattr(settings, "webcrt_keepalive_sec", 0) or 0) or None
|
ka = int(getattr(settings, "webcrt_keepalive_sec", 0) or 0) or None
|
||||||
|
log = session_log
|
||||||
|
if progress_cb is not None:
|
||||||
|
class _TeeLog(_ProgressSessionLog):
|
||||||
|
def write(self, data: Any) -> int: # noqa: ANN401
|
||||||
|
n = super().write(data)
|
||||||
|
if session_log is None:
|
||||||
|
return n
|
||||||
|
try:
|
||||||
|
if isinstance(data, (bytes, bytearray)):
|
||||||
|
session_log.write(data)
|
||||||
|
else:
|
||||||
|
session_log.write(str(data).encode("utf-8", errors="replace"))
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
session_log.write(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return n
|
||||||
|
|
||||||
|
def getvalue(self) -> bytes:
|
||||||
|
if session_log is not None and hasattr(session_log, "getvalue"):
|
||||||
|
try:
|
||||||
|
raw = session_log.getvalue()
|
||||||
|
return raw if isinstance(raw, (bytes, bytearray)) else str(raw).encode()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return super().getvalue()
|
||||||
|
|
||||||
|
log = _TeeLog(progress_cb)
|
||||||
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(
|
return _connect_via_linux_hop(
|
||||||
creds,
|
creds,
|
||||||
session_timeout=session_timeout,
|
session_timeout=session_timeout,
|
||||||
session_log=session_log,
|
session_log=log,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
keepalive=ka,
|
keepalive=ka,
|
||||||
)
|
)
|
||||||
|
|
@ -816,23 +1022,27 @@ def open_netmiko_connection(
|
||||||
return _connect_via_bastion(
|
return _connect_via_bastion(
|
||||||
creds,
|
creds,
|
||||||
session_timeout=session_timeout,
|
session_timeout=session_timeout,
|
||||||
session_log=session_log,
|
session_log=log,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
keepalive=ka,
|
keepalive=ka,
|
||||||
|
progress_cb=progress_cb,
|
||||||
)
|
)
|
||||||
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=log,
|
||||||
cols=cols,
|
cols=cols,
|
||||||
rows=rows,
|
rows=rows,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
keepalive=ka,
|
keepalive=ka,
|
||||||
|
progress_cb=progress_cb,
|
||||||
)
|
)
|
||||||
|
if progress_cb is not None:
|
||||||
|
_emit_progress(progress_cb, "\r\n[netx] direct connect…\r\n")
|
||||||
return _connect_direct(
|
return _connect_direct(
|
||||||
creds,
|
creds,
|
||||||
session_timeout=session_timeout,
|
session_timeout=session_timeout,
|
||||||
session_log=session_log,
|
session_log=log,
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
keepalive=ka,
|
keepalive=ka,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -458,14 +458,34 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Wait for async connect without blocking the event loop; emit phase updates.
|
# Wait for async connect without blocking the event loop; emit phase updates
|
||||||
|
# and live login transcript (hop/stelnet/bastion) into the terminal.
|
||||||
if sess.state == "connecting":
|
if sess.state == "connecting":
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
budget = max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 15
|
budget = max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 15
|
||||||
deadline = time.time() + budget
|
deadline = time.time() + budget
|
||||||
|
|
||||||
|
async def _flush_connect_echo(cur_sess: Any) -> None:
|
||||||
|
try:
|
||||||
|
text = cur_sess.drain_connect_echo()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
raw = _encode_text(text, getattr(cur_sess, "encoding", None) or "utf-8")
|
||||||
|
try:
|
||||||
|
await websocket.send_bytes(raw)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await websocket.send_json({"type": "stdout", "data": text})
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
cur = get_session(session_id) or sess
|
cur = get_session(session_id) or sess
|
||||||
if cur.state != "connecting":
|
if cur.state != "connecting":
|
||||||
|
# Drain any last connect-echo chunks before leaving the wait loop.
|
||||||
|
await _flush_connect_echo(cur)
|
||||||
sess = cur
|
sess = cur
|
||||||
break
|
break
|
||||||
elapsed = max(0.0, time.time() - float(cur.connect_started_at or time.time()))
|
elapsed = max(0.0, time.time() - float(cur.connect_started_at or time.time()))
|
||||||
|
|
@ -484,6 +504,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
except Exception:
|
except Exception:
|
||||||
# Client dropped during connect wait (StrictMode remount etc.) — keep PTY.
|
# Client dropped during connect wait (StrictMode remount etc.) — keep PTY.
|
||||||
return
|
return
|
||||||
|
await _flush_connect_echo(cur)
|
||||||
remaining = deadline - time.time()
|
remaining = deadline - time.time()
|
||||||
if remaining <= 0:
|
if remaining <= 0:
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
|
|
@ -491,7 +512,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
)
|
)
|
||||||
await websocket.close(code=4502)
|
await websocket.close(code=4502)
|
||||||
return
|
return
|
||||||
slice_timeout = min(1.0, max(0.2, remaining))
|
slice_timeout = min(0.35, max(0.15, remaining))
|
||||||
try:
|
try:
|
||||||
await loop.run_in_executor(
|
await loop.run_in_executor(
|
||||||
webcrt_io_executor(),
|
webcrt_io_executor(),
|
||||||
|
|
@ -503,6 +524,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
if sess.state == "connecting":
|
if sess.state == "connecting":
|
||||||
# wait_session_ready should not return while still connecting.
|
# wait_session_ready should not return while still connecting.
|
||||||
continue
|
continue
|
||||||
|
await _flush_connect_echo(sess)
|
||||||
break
|
break
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
if exc.status_code == 504:
|
if exc.status_code == 504:
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,10 @@ class WebcrtSession:
|
||||||
bootstrap_output: bytes = b""
|
bootstrap_output: bytes = b""
|
||||||
# First WS attach gets login bootstrap; later attaches prefer session-log tail.
|
# First WS attach gets login bootstrap; later attaches prefer session-log tail.
|
||||||
bootstrap_replayed: bool = False
|
bootstrap_replayed: bool = False
|
||||||
|
# Live connect transcript (hop/stelnet/bastion) streamed to WS before ready.
|
||||||
|
connect_echo_acc: str = ""
|
||||||
|
connect_echo_q: queue.Queue = field(default_factory=queue.Queue, repr=False)
|
||||||
|
_connect_echo_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
||||||
needs_live_prompt: bool = True
|
needs_live_prompt: bool = True
|
||||||
# React StrictMode remounts open a second WS before the first fully tears down.
|
# React StrictMode remounts open a second WS before the first fully tears down.
|
||||||
# Only the newest attach_gen may consume out_queue / mark detach.
|
# Only the newest attach_gen may consume out_queue / mark detach.
|
||||||
|
|
@ -87,6 +91,32 @@ class WebcrtSession:
|
||||||
def touch(self) -> None:
|
def touch(self) -> None:
|
||||||
self.last_activity = time.time()
|
self.last_activity = time.time()
|
||||||
|
|
||||||
|
def push_connect_echo(self, text: str) -> None:
|
||||||
|
"""Queue login transcript for the WS wait-loop (thread-safe)."""
|
||||||
|
chunk = str(text or "")
|
||||||
|
if not chunk or self.closed:
|
||||||
|
return
|
||||||
|
with self._connect_echo_lock:
|
||||||
|
self.connect_echo_acc += chunk
|
||||||
|
try:
|
||||||
|
self.connect_echo_q.put_nowait(chunk)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def drain_connect_echo(self) -> str:
|
||||||
|
"""Pop all pending connect-echo chunks (called from asyncio WS loop)."""
|
||||||
|
parts: list[str] = []
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
parts.append(self.connect_echo_q.get_nowait())
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
def connect_echo_text(self) -> str:
|
||||||
|
with self._connect_echo_lock:
|
||||||
|
return str(self.connect_echo_acc or "")
|
||||||
|
|
||||||
def close_sftp(self) -> None:
|
def close_sftp(self) -> None:
|
||||||
with self._sftp_lock:
|
with self._sftp_lock:
|
||||||
sftp = self._sftp
|
sftp = self._sftp
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,10 @@ def _finish_connect(
|
||||||
client: str,
|
client: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
log_buf = io.BytesIO()
|
log_buf = io.BytesIO()
|
||||||
|
|
||||||
|
def _progress(text: str) -> None:
|
||||||
|
sess.push_connect_echo(text)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
conn = open_netmiko_connection(
|
conn = open_netmiko_connection(
|
||||||
creds,
|
creds,
|
||||||
|
|
@ -232,15 +236,20 @@ def _finish_connect(
|
||||||
rows=sess.rows,
|
rows=sess.rows,
|
||||||
interactive=True,
|
interactive=True,
|
||||||
keepalive=int(sess.keepalive_sec or 0),
|
keepalive=int(sess.keepalive_sec or 0),
|
||||||
|
progress_cb=_progress,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
partial = _session_log_text(log_buf).strip()
|
partial = (_session_log_text(log_buf) or sess.connect_echo_text()).strip()
|
||||||
from .ne_cli_errors import format_cli_failure
|
from .ne_cli_errors import format_cli_failure
|
||||||
|
|
||||||
classified = format_cli_failure(exc, partial)
|
classified = format_cli_failure(exc, partial)
|
||||||
detail = f"connect_failed:{classified}"
|
detail = f"connect_failed:{classified}"
|
||||||
if partial:
|
if partial:
|
||||||
detail = f"{detail}\n--- device transcript ---\n{partial[-4000:]}"
|
detail = f"{detail}\n--- device transcript ---\n{partial[-4000:]}"
|
||||||
|
# Surface failure transcript on the live terminal before status:error.
|
||||||
|
if partial and not sess.connect_echo_text().strip():
|
||||||
|
sess.push_connect_echo(partial[-4000:])
|
||||||
|
sess.push_connect_echo(f"\r\n[netx] connect failed: {classified}\r\n")
|
||||||
sess.state = "error"
|
sess.state = "error"
|
||||||
sess.connect_error = detail
|
sess.connect_error = detail
|
||||||
sess.connect_finished_at = time.time()
|
sess.connect_finished_at = time.time()
|
||||||
|
|
@ -271,30 +280,50 @@ def _finish_connect(
|
||||||
early = _capture_raw_channel(conn, duration=0.35)
|
early = _capture_raw_channel(conn, duration=0.35)
|
||||||
except Exception:
|
except Exception:
|
||||||
early = ""
|
early = ""
|
||||||
|
if early:
|
||||||
|
sess.push_connect_echo(early)
|
||||||
seed = f"{pre_log}{early}"
|
seed = f"{pre_log}{early}"
|
||||||
already_prompted = _looks_like_cli_prompt(seed)
|
already_prompted = _looks_like_cli_prompt(seed)
|
||||||
primed = ""
|
primed = ""
|
||||||
# Do not send Enter at Username:/Password: or Huawei password-change [Y/N]:
|
# Auto-answer Huawei post-login password-change (Netmiko would have sent N).
|
||||||
# (Netmiko telnet_login already answers password-change with "N").
|
if _looks_like_password_change_prompt(seed):
|
||||||
if _looks_like_login_prompt(seed) or _looks_like_password_change_prompt(seed):
|
try:
|
||||||
|
conn.write_channel("N" + (getattr(conn, "RETURN", None) or "\n"))
|
||||||
|
sess.push_connect_echo("\r\n[netx] password-change → N\r\n")
|
||||||
|
primed = _capture_raw_channel(conn, duration=0.9)
|
||||||
|
if primed:
|
||||||
|
sess.push_connect_echo(primed)
|
||||||
|
except Exception:
|
||||||
|
primed = ""
|
||||||
|
elif _looks_like_login_prompt(seed):
|
||||||
|
# Do not send Enter at Username:/Password: (would empty-submit login).
|
||||||
try:
|
try:
|
||||||
primed = _capture_raw_channel(conn, duration=0.9)
|
primed = _capture_raw_channel(conn, duration=0.9)
|
||||||
|
if primed:
|
||||||
|
sess.push_connect_echo(primed)
|
||||||
except Exception:
|
except Exception:
|
||||||
primed = ""
|
primed = ""
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
primed = _prime_interactive_channel(conn, already_prompted=already_prompted)
|
primed = _prime_interactive_channel(conn, already_prompted=already_prompted)
|
||||||
|
if primed:
|
||||||
|
sess.push_connect_echo(primed)
|
||||||
except Exception:
|
except Exception:
|
||||||
primed = ""
|
primed = ""
|
||||||
combined = f"{seed}{primed}"
|
combined = f"{seed}{primed}"
|
||||||
# Final settle: keep stragglers in bootstrap (normalize collapses duplicate prompts).
|
# Final settle: keep stragglers in bootstrap (normalize collapses duplicate prompts).
|
||||||
try:
|
try:
|
||||||
combined += _capture_raw_channel(conn, duration=0.35)
|
more = _capture_raw_channel(conn, duration=0.35)
|
||||||
|
if more:
|
||||||
|
sess.push_connect_echo(more)
|
||||||
|
combined += more
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if not str(combined).strip():
|
if not str(combined).strip():
|
||||||
try:
|
try:
|
||||||
combined = _drain_channel(conn, rounds=6, wait=0.08)
|
combined = _drain_channel(conn, rounds=6, wait=0.08)
|
||||||
|
if combined:
|
||||||
|
sess.push_connect_echo(combined)
|
||||||
except Exception:
|
except Exception:
|
||||||
combined = ""
|
combined = ""
|
||||||
bootstrap = prepare_bootstrap_output(combined)
|
bootstrap = prepare_bootstrap_output(combined)
|
||||||
|
|
@ -304,8 +333,21 @@ def _finish_connect(
|
||||||
except Exception:
|
except Exception:
|
||||||
leftover = ""
|
leftover = ""
|
||||||
if leftover and leftover.strip() not in {":", ">", "#", "]", "$"}:
|
if leftover and leftover.strip() not in {":", ">", "#", "]", "$"}:
|
||||||
|
sess.push_connect_echo(leftover)
|
||||||
bootstrap = prepare_bootstrap_output(f"{bootstrap}{leftover}")
|
bootstrap = prepare_bootstrap_output(f"{bootstrap}{leftover}")
|
||||||
|
|
||||||
|
# Prefer live echo already shown on the terminal; only bootstrap the delta.
|
||||||
|
echoed = sess.connect_echo_text()
|
||||||
|
if echoed and bootstrap:
|
||||||
|
# If bootstrap is fully covered by live echo, skip replaying it.
|
||||||
|
norm_boot = bootstrap.replace("\r\n", "\n").strip()
|
||||||
|
norm_echo = echoed.replace("\r\n", "\n")
|
||||||
|
if norm_boot and norm_boot in norm_echo:
|
||||||
|
bootstrap = ""
|
||||||
|
elif norm_echo and norm_boot.startswith(norm_echo.strip()[-min(200, len(norm_echo)) :]):
|
||||||
|
# Overlap at the end of echo — keep only unseen suffix when possible.
|
||||||
|
bootstrap = bootstrap
|
||||||
|
|
||||||
hop_guard = get_cli_hop_guard(conn)
|
hop_guard = get_cli_hop_guard(conn)
|
||||||
sess.conn = conn
|
sess.conn = conn
|
||||||
sess.cli_hop_guard = bool(hop_guard)
|
sess.cli_hop_guard = bool(hop_guard)
|
||||||
|
|
@ -313,17 +355,19 @@ def _finish_connect(
|
||||||
sess.bootstrap_output = _encode_text(str(bootstrap or ""), sess.encoding)
|
sess.bootstrap_output = _encode_text(str(bootstrap or ""), sess.encoding)
|
||||||
# Nudge Enter on WS attach only when we still need a shell prompt.
|
# Nudge Enter on WS attach only when we still need a shell prompt.
|
||||||
# Never when already at CLI prompt or Username:/Password: (would empty-submit login).
|
# Never when already at CLI prompt or Username:/Password: (would empty-submit login).
|
||||||
|
final_view = bootstrap or echoed
|
||||||
sess.needs_live_prompt = (
|
sess.needs_live_prompt = (
|
||||||
not _looks_like_cli_prompt(bootstrap) and not _looks_like_login_prompt(bootstrap)
|
not _looks_like_cli_prompt(final_view) and not _looks_like_login_prompt(final_view)
|
||||||
)
|
)
|
||||||
sess.open_session_log()
|
sess.open_session_log()
|
||||||
if bootstrap:
|
log_seed = echoed or bootstrap
|
||||||
sess.append_session_log(bootstrap if bootstrap.endswith("\n") else bootstrap + "\n")
|
if log_seed:
|
||||||
|
sess.append_session_log(log_seed if str(log_seed).endswith("\n") else str(log_seed) + "\n")
|
||||||
sess.start_reader()
|
sess.start_reader()
|
||||||
# Drop late prompt echoes that race into the queue right after reader start.
|
# Drop late prompt echoes that race into the queue right after reader start.
|
||||||
prompt_hint = ""
|
prompt_hint = ""
|
||||||
if bootstrap:
|
if final_view:
|
||||||
prompt_hint = str(bootstrap).replace("\r\n", "\n").replace("\r", "\n").strip().split("\n")[-1].strip()
|
prompt_hint = str(final_view).replace("\r\n", "\n").replace("\r", "\n").strip().split("\n")[-1].strip()
|
||||||
settle_deadline = time.time() + 0.45
|
settle_deadline = time.time() + 0.45
|
||||||
while time.time() < settle_deadline:
|
while time.time() < settle_deadline:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -280,7 +280,7 @@ class BastionConnectImplTests(unittest.TestCase):
|
||||||
password="bastion-pass",
|
password="bastion-pass",
|
||||||
timeout=unittest.mock.ANY,
|
timeout=unittest.mock.ANY,
|
||||||
)
|
)
|
||||||
interact.assert_called_once_with(conn, "target-user", "target-pass")
|
interact.assert_called_once_with(conn, "target-user", "target-pass", progress_cb=None)
|
||||||
|
|
||||||
|
|
||||||
class BastionInteractiveHandlerTests(unittest.TestCase):
|
class BastionInteractiveHandlerTests(unittest.TestCase):
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,13 @@ class PromptDetectTests(unittest.TestCase):
|
||||||
self.assertFalse(cont)
|
self.assertFalse(cont)
|
||||||
self.assertTrue(save)
|
self.assertTrue(save)
|
||||||
|
|
||||||
|
def test_host_key_wrapped_yn_line(self) -> None:
|
||||||
|
cont, save = _prompt_needs_host_key_confirm(
|
||||||
|
"The server is not authenticated. Continue to access it?\n[Y/N]:"
|
||||||
|
)
|
||||||
|
self.assertTrue(cont)
|
||||||
|
self.assertFalse(save)
|
||||||
|
|
||||||
def test_password_change_not_host_key(self) -> None:
|
def test_password_change_not_host_key(self) -> None:
|
||||||
cont, save = _prompt_needs_host_key_confirm("Change now? [Y/N]:")
|
cont, save = _prompt_needs_host_key_confirm("Change now? [Y/N]:")
|
||||||
self.assertFalse(cont)
|
self.assertFalse(cont)
|
||||||
|
|
@ -80,11 +87,14 @@ class HuaweiStelnetAuthTests(unittest.TestCase):
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
conn = MagicMock()
|
conn = MagicMock()
|
||||||
_interactive_target_auth(conn, "ipran", "secret")
|
seen: list[str] = []
|
||||||
|
_interactive_target_auth(conn, "ipran", "secret", progress_cb=seen.append)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[c.args[1] for c in mock_send.call_args_list],
|
[c.args[1] for c in mock_send.call_args_list],
|
||||||
["ipran", "Y", "N", "secret"],
|
["ipran", "Y", "N", "secret"],
|
||||||
)
|
)
|
||||||
|
self.assertTrue(any("host-key continue" in p for p in seen))
|
||||||
|
self.assertTrue(any("Please input the username" in p for p in seen))
|
||||||
|
|
||||||
@patch("netx_api.ne_session_connect._read_channel")
|
@patch("netx_api.ne_session_connect._read_channel")
|
||||||
@patch("netx_api.ne_session_connect._send_line")
|
@patch("netx_api.ne_session_connect._send_line")
|
||||||
|
|
@ -102,6 +112,9 @@ class HuaweiStelnetAuthTests(unittest.TestCase):
|
||||||
"Enter password:",
|
"Enter password:",
|
||||||
"<TARGET>\n",
|
"<TARGET>\n",
|
||||||
"",
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
]
|
]
|
||||||
conn = MagicMock()
|
conn = MagicMock()
|
||||||
_interactive_target_auth(conn, "ipran", "secret")
|
_interactive_target_auth(conn, "ipran", "secret")
|
||||||
|
|
|
||||||
|
|
@ -1272,7 +1272,7 @@ const en = {
|
||||||
creating: "Creating session",
|
creating: "Creating session",
|
||||||
authenticating: "Authenticating",
|
authenticating: "Authenticating",
|
||||||
waitingPrompt: "Waiting for device prompt",
|
waitingPrompt: "Waiting for device prompt",
|
||||||
hint: "Slow devices may take more than 10 seconds",
|
hint: "Login progress streams into the terminal; slow devices may take >10s",
|
||||||
},
|
},
|
||||||
tabMenu: {
|
tabMenu: {
|
||||||
close: "Close",
|
close: "Close",
|
||||||
|
|
|
||||||
|
|
@ -1263,7 +1263,7 @@ const zh = {
|
||||||
creating: "创建会话",
|
creating: "创建会话",
|
||||||
authenticating: "正在认证",
|
authenticating: "正在认证",
|
||||||
waitingPrompt: "等待设备提示符",
|
waitingPrompt: "等待设备提示符",
|
||||||
hint: "设备较慢时可能需要十几秒,请稍候",
|
hint: "登录过程会实时显示在终端中;设备较慢时可能需要十几秒",
|
||||||
},
|
},
|
||||||
tabMenu: {
|
tabMenu: {
|
||||||
close: "关闭",
|
close: "关闭",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue