diff --git a/netx_api/ne_session_connect.py b/netx_api/ne_session_connect.py index 78ed727..c700177 100644 --- a/netx_api/ne_session_connect.py +++ b/netx_api/ne_session_connect.py @@ -1,8 +1,10 @@ """Netmiko connect paths: direct, vendor CLI hop, bastion, and Linux jump.""" from __future__ import annotations +import io import logging import re +import threading import time 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). 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] @@ -154,6 +159,9 @@ def _interactive_driver_class(base_cls: type) -> type: def session_preparation(self) -> 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 del force_data try: @@ -166,6 +174,44 @@ def _interactive_driver_class(base_cls: type) -> type: 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: """Cisco IOSv-friendly session prep: avoid cmd_verify on terminal width/length.""" @@ -280,7 +326,10 @@ def _netmiko_over_ssh_client( if chan_transport is not None: chan_transport.set_keepalive(self.keepalive) self.channel = SSHChannel(conn=self.remote_conn, encoding=self.encoding) - self.special_login_handler() + # 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() dev = _base_connect_kwargs( device_type=device_type, @@ -356,14 +405,29 @@ def _connect_direct( def _read_channel(conn: ConnectHandler, wait: float = 0.5, max_loops: int = 40) -> str: - time.sleep(wait) + if wait > 0: + time.sleep(wait) chunks: list[str] = [] for _ in range(max_loops): - part = conn.read_channel() + try: + 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 "" + if text.startswith(" None: conn.write_channel(text) -def _auth_prompt_tail(text: str) -> str: - """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()] - return lines[-1] if lines else "" +_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\].*?\x07|\x1b.") + + +def _strip_ansi(text: str) -> str: + 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]: @@ -386,11 +460,15 @@ def _prompt_needs_auth(text: str) -> tuple[bool, bool]: tail = _auth_prompt_tail(text).lower() if not tail: return False, False - # Prefer last-line match so earlier echoed prompts in ``acc`` do not stick forever. 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 @@ -400,22 +478,40 @@ def _prompt_needs_host_key_confirm(text: str) -> tuple[bool, bool]: Returns ``(continue_access, save_public_key)`` for: - ``The server is not authenticated. Continue to access it? [Y/N]:`` → Y - ``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) - if not tail: + block = _auth_prompt_tail(text, lines=3) + if not block: return False, False - low = tail.lower() + low = block.lower() # 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 continue_access = bool( - re.search(r"(?:not\s+authenticated|continue\s+to\s+access)", low) - and re.search(r"\[Y/N\]\s*:\s*$", tail, flags=re.I) + re.search( + r"(?:not\s+authenticated|continue\s+to\s+access|未认证|继续访问|是否继续)", + low, + ) ) save_key = bool( - re.search(r"save\s+the\s+server'?s?\s+public\s+key", low) - and re.search(r"\[Y/N\]\s*:\s*$", tail, flags=re.I) + re.search( + 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 @@ -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)) -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.""" from .ne_cli_errors import find_auth_failure_snippet # 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_pass = False answered_continue = False answered_save_key = False + empty_after_pass = 0 acc = "" 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: acc += buf + _emit_progress(progress_cb, buf) denied = find_auth_failure_snippet(acc) if 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. continue_access, save_key = _prompt_needs_host_key_confirm(acc) if continue_access and not answered_continue: + _emit_progress(progress_cb, "\r\n[netx] host-key continue → Y\r\n") _send_line(conn, "Y") answered_continue = True continue 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") answered_save_key = True continue need_user, need_pass = _prompt_needs_auth(acc) if need_pass and not sent_pass: + _emit_progress(progress_cb, "\r\n[netx] sending password\r\n") _send_line(conn, password) sent_pass = True + empty_after_pass = 0 continue if need_user and not sent_user: + _emit_progress(progress_cb, f"\r\n[netx] sending username ({username})\r\n") _send_line(conn, username) sent_user = True continue @@ -477,15 +586,21 @@ def _interactive_target_auth(conn: ConnectHandler, username: str, password: str) denied = find_auth_failure_snippet(acc) if 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) or not buf.strip(): + if _looks_like_target_cli_prompt(acc): return - # Banner mid-stream after password — keep reading briefly within deadline. - time.sleep(0.2) + # Do not treat a single empty read right after password as success — + # 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 if not buf.strip(): - time.sleep(0.3) + time.sleep(0.2) continue # Huawei stelnet often reprints the hop ``[sysname]`` after host-key trust and # *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: raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}") return - time.sleep(0.3) + time.sleep(0.15) denied = find_auth_failure_snippet(acc) if denied: raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}") if not sent_pass: 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: @@ -553,6 +670,7 @@ def _connect_via_cli_hop( rows: int | None = None, interactive: bool = False, keepalive: int | None = None, + progress_cb: Any = None, ) -> ConnectHandler: """Login to ZTE/Huawei/Cisco hop NE, run CLI jump command, then target secondary auth.""" hop_host = str(creds.get("hop_host") or "").strip() @@ -574,12 +692,15 @@ def _connect_via_cli_hop( session_log=session_log, 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) try: # 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. _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) if not hop_prompt: # 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") except Exception: _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) # 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. if interactive and not hop_prompt: - for _ in range(4): - more = _read_channel(conn, wait=0.4, max_loops=8) + for _ in range(6): + more = _read_channel(conn, wait=0.3, max_loops=10) if more: + _emit_progress(progress_cb, more) pre += more hop_prompt = extract_cli_prompt_marker(pre) if hop_prompt: break 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) - _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( conn, hop_prompt=hop_prompt, @@ -630,6 +761,35 @@ def _connect_via_cli_hop( 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( creds: dict[str, Any], *, @@ -637,6 +797,7 @@ def _connect_via_bastion( session_log: Any = None, interactive: bool = False, keepalive: int | None = None, + progress_cb: Any = None, ) -> ConnectHandler: """SSH to bastion with composite username; bastion proxies to target (protocol proxy).""" 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"]) hop_port = int(creds.get("hop_port") or 22) 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 try: ssh_client = _bastion_ssh_connect( @@ -663,6 +828,7 @@ def _connect_via_bastion( password=hop_pass, timeout=timeout, ) + _emit_progress(progress_cb, "\r\n[netx] bastion transport OK; opening shell…\r\n") conn = _netmiko_over_ssh_client( ssh_client, device_type=device_type, @@ -685,18 +851,26 @@ def _connect_via_bastion( raise auth_mode = str(creds.get("hop_target_auth_mode") or "bastion_managed").strip().lower() - if auth_mode == "manual": - target_pass = str(creds.get("password") or "") - if target_pass: - try: - _read_channel(conn, wait=0.5) - _interactive_target_auth(conn, str(creds["username"]), target_pass) - except Exception: - try: - conn.disconnect() - except Exception: - pass - raise + try: + if auth_mode == "manual": + target_pass = str(creds.get("password") or "") + if target_pass: + _maybe_secondary_target_auth(conn, creds, progress_cb=progress_cb, force=True) + else: + # 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: + try: + conn.disconnect() + except Exception: + pass + raise return conn @@ -793,22 +967,54 @@ def open_netmiko_connection( rows: int | None = None, interactive: bool = False, keepalive: int | None = None, + progress_cb: Any = None, ) -> ConnectHandler: """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. + + ``progress_cb`` receives live login transcript chunks (WebCRT connect echo). """ ka = keepalive if ka is None and interactive: 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"): vendor = _hop_vendor(creds) if vendor == "linux": return _connect_via_linux_hop( creds, session_timeout=session_timeout, - session_log=session_log, + session_log=log, interactive=interactive, keepalive=ka, ) @@ -816,23 +1022,27 @@ def open_netmiko_connection( return _connect_via_bastion( creds, session_timeout=session_timeout, - session_log=session_log, + session_log=log, interactive=interactive, keepalive=ka, + progress_cb=progress_cb, ) return _connect_via_cli_hop( creds, session_timeout=session_timeout, - session_log=session_log, + session_log=log, cols=cols, rows=rows, interactive=interactive, 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( creds, session_timeout=session_timeout, - session_log=session_log, + session_log=log, interactive=interactive, keepalive=ka, ) diff --git a/netx_api/webcrt_router.py b/netx_api/webcrt_router.py index 8963435..8ae08d4 100644 --- a/netx_api/webcrt_router.py +++ b/netx_api/webcrt_router.py @@ -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": loop = asyncio.get_running_loop() budget = max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 15 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: cur = get_session(session_id) or sess if cur.state != "connecting": + # Drain any last connect-echo chunks before leaving the wait loop. + await _flush_connect_echo(cur) sess = cur break 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: # Client dropped during connect wait (StrictMode remount etc.) — keep PTY. return + await _flush_connect_echo(cur) remaining = deadline - time.time() if remaining <= 0: await websocket.send_json( @@ -491,7 +512,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: ) await websocket.close(code=4502) return - slice_timeout = min(1.0, max(0.2, remaining)) + slice_timeout = min(0.35, max(0.15, remaining)) try: await loop.run_in_executor( webcrt_io_executor(), @@ -503,6 +524,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: if sess.state == "connecting": # wait_session_ready should not return while still connecting. continue + await _flush_connect_echo(sess) break except HTTPException as exc: if exc.status_code == 504: diff --git a/netx_api/webcrt_session_model.py b/netx_api/webcrt_session_model.py index 4198841..e43abb3 100644 --- a/netx_api/webcrt_session_model.py +++ b/netx_api/webcrt_session_model.py @@ -59,6 +59,10 @@ class WebcrtSession: bootstrap_output: bytes = b"" # First WS attach gets login bootstrap; later attaches prefer session-log tail. 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 # React StrictMode remounts open a second WS before the first fully tears down. # Only the newest attach_gen may consume out_queue / mark detach. @@ -87,6 +91,32 @@ class WebcrtSession: def touch(self) -> None: 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: with self._sftp_lock: sftp = self._sftp diff --git a/netx_api/webcrt_session_registry.py b/netx_api/webcrt_session_registry.py index 3a1773d..63d513b 100644 --- a/netx_api/webcrt_session_registry.py +++ b/netx_api/webcrt_session_registry.py @@ -223,6 +223,10 @@ def _finish_connect( client: str, ) -> None: log_buf = io.BytesIO() + + def _progress(text: str) -> None: + sess.push_connect_echo(text) + try: conn = open_netmiko_connection( creds, @@ -232,15 +236,20 @@ def _finish_connect( rows=sess.rows, interactive=True, keepalive=int(sess.keepalive_sec or 0), + progress_cb=_progress, ) 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 classified = format_cli_failure(exc, partial) detail = f"connect_failed:{classified}" if partial: 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.connect_error = detail sess.connect_finished_at = time.time() @@ -271,30 +280,50 @@ def _finish_connect( early = _capture_raw_channel(conn, duration=0.35) except Exception: early = "" + if early: + sess.push_connect_echo(early) seed = f"{pre_log}{early}" already_prompted = _looks_like_cli_prompt(seed) primed = "" - # Do not send Enter at Username:/Password: or Huawei password-change [Y/N]: - # (Netmiko telnet_login already answers password-change with "N"). - if _looks_like_login_prompt(seed) or _looks_like_password_change_prompt(seed): + # Auto-answer Huawei post-login password-change (Netmiko would have sent N). + if _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: primed = _capture_raw_channel(conn, duration=0.9) + if primed: + sess.push_connect_echo(primed) except Exception: primed = "" else: try: primed = _prime_interactive_channel(conn, already_prompted=already_prompted) + if primed: + sess.push_connect_echo(primed) except Exception: primed = "" combined = f"{seed}{primed}" # Final settle: keep stragglers in bootstrap (normalize collapses duplicate prompts). 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: pass if not str(combined).strip(): try: combined = _drain_channel(conn, rounds=6, wait=0.08) + if combined: + sess.push_connect_echo(combined) except Exception: combined = "" bootstrap = prepare_bootstrap_output(combined) @@ -304,8 +333,21 @@ def _finish_connect( except Exception: leftover = "" if leftover and leftover.strip() not in {":", ">", "#", "]", "$"}: + sess.push_connect_echo(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) sess.conn = conn sess.cli_hop_guard = bool(hop_guard) @@ -313,17 +355,19 @@ def _finish_connect( sess.bootstrap_output = _encode_text(str(bootstrap or ""), sess.encoding) # 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). + final_view = bootstrap or echoed 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() - if bootstrap: - sess.append_session_log(bootstrap if bootstrap.endswith("\n") else bootstrap + "\n") + log_seed = echoed or bootstrap + if log_seed: + sess.append_session_log(log_seed if str(log_seed).endswith("\n") else str(log_seed) + "\n") sess.start_reader() # Drop late prompt echoes that race into the queue right after reader start. prompt_hint = "" - if bootstrap: - prompt_hint = str(bootstrap).replace("\r\n", "\n").replace("\r", "\n").strip().split("\n")[-1].strip() + if final_view: + prompt_hint = str(final_view).replace("\r\n", "\n").replace("\r", "\n").strip().split("\n")[-1].strip() settle_deadline = time.time() + 0.45 while time.time() < settle_deadline: try: diff --git a/tests/test_bastion_hop.py b/tests/test_bastion_hop.py index 7e76c14..81e85de 100644 --- a/tests/test_bastion_hop.py +++ b/tests/test_bastion_hop.py @@ -280,7 +280,7 @@ class BastionConnectImplTests(unittest.TestCase): password="bastion-pass", 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): diff --git a/tests/test_cli_hop_target_auth.py b/tests/test_cli_hop_target_auth.py index 2059376..a57c142 100644 --- a/tests/test_cli_hop_target_auth.py +++ b/tests/test_cli_hop_target_auth.py @@ -32,6 +32,13 @@ class PromptDetectTests(unittest.TestCase): self.assertFalse(cont) 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: cont, save = _prompt_needs_host_key_confirm("Change now? [Y/N]:") self.assertFalse(cont) @@ -80,11 +87,14 @@ class HuaweiStelnetAuthTests(unittest.TestCase): ), ] conn = MagicMock() - _interactive_target_auth(conn, "ipran", "secret") + seen: list[str] = [] + _interactive_target_auth(conn, "ipran", "secret", progress_cb=seen.append) self.assertEqual( [c.args[1] for c in mock_send.call_args_list], ["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._send_line") @@ -102,6 +112,9 @@ class HuaweiStelnetAuthTests(unittest.TestCase): "Enter password:", "\n", "", + "", + "", + "", ] conn = MagicMock() _interactive_target_auth(conn, "ipran", "secret") diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 044712c..1003300 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1272,7 +1272,7 @@ const en = { creating: "Creating session", authenticating: "Authenticating", 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: { close: "Close", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 898cace..a6bc6ec 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1263,7 +1263,7 @@ const zh = { creating: "创建会话", authenticating: "正在认证", waitingPrompt: "等待设备提示符", - hint: "设备较慢时可能需要十几秒,请稍候", + hint: "登录过程会实时显示在终端中;设备较慢时可能需要十几秒", }, tabMenu: { close: "关闭",