diff --git a/netx_api/ne_connect.py b/netx_api/ne_connect.py index 7ee5af2..096b5d9 100644 --- a/netx_api/ne_connect.py +++ b/netx_api/ne_connect.py @@ -194,6 +194,11 @@ def _classify_connect_error(creds: dict[str, Any], exc: BaseException) -> str: return "target_auth_failed: " + detail if "timed out" in raw or "timeout" in raw or "hop_connect_failed" in raw: return "hop_connect_failed: " + detail + if "no existing session" in raw: + return ( + "hop_connect_failed: SSH handshake dropped (often hop VTY/session limit " + "or concurrent WebCRT). Close spare terminals and retry. " + detail + ) if hop_v in ("linux", "bastion"): if "vault" in raw or "bastion" in raw: return "bastion_auth_failed: " + detail @@ -247,7 +252,15 @@ def _probe_device(creds: dict[str, Any]) -> tuple[str, str, str | None, str]: session_timeout = 180 if creds.get("hop_enabled") else None conn = None try: - conn = open_netmiko_connection(creds, session_timeout=session_timeout) + # CLI hop (Huawei/ZTE/Cisco): use interactive driver so Change-now / prompt + # waits match WebCRT (stock Netmiko ``[\]>]`` matches ``[Y/N]`` and breaks hop login). + hop_v = str(creds.get("hop_vendor") or "").strip().lower() + use_interactive = bool(creds.get("hop_enabled")) and hop_v not in ("linux", "bastion", "") + conn = open_netmiko_connection( + creds, + session_timeout=session_timeout, + interactive=use_interactive, + ) prompt = str(conn.find_prompt() or "") command = hostname_probe_command(creds["device_type"], vendor) output = "" diff --git a/netx_api/ne_session_connect.py b/netx_api/ne_session_connect.py index e3ee1c3..3b1bbcc 100644 --- a/netx_api/ne_session_connect.py +++ b/netx_api/ne_session_connect.py @@ -142,13 +142,18 @@ def _netmiko_driver_class(device_type: str) -> type: 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) - 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. + Skips Netmiko session prep (prompt discovery, terminal length/width, force RETURN). + Huawei password-change is handled with a *safe* prompt pattern (never bare ``]``, + which matches ``[Y/N]`` and scrambles login). """ + _REAL_PROMPT = r"(?:<[^>\r\n]{1,64}>|\[[^\]\r\n]{1,64}\])" + _mro_names = {getattr(c, "__name__", "") for c in getattr(base_cls, "__mro__", ())} + _is_huawei_telnet = "HuaweiTelnet" in _mro_names or getattr(base_cls, "__name__", "") == "HuaweiTelnet" + _is_huawei_ssh = bool(_mro_names & {"HuaweiSSH", "HuaweiVrpv8SSH"}) or getattr( + base_cls, "__name__", "" + ) in {"HuaweiSSH", "HuaweiVrpv8SSH"} + class _InteractiveSession(base_cls): # type: ignore[misc,valid-type] def disable_paging(self, *args: Any, **kwargs: Any) -> str: # noqa: ANN401 return "" @@ -160,7 +165,35 @@ def _interactive_driver_class(base_cls: type) -> type: return None def special_login_handler(self, delay_factor: float = 1.0) -> None: # noqa: ARG002 - return None + # Non-Huawei: leave the PTY alone (SecureCRT-like). + if not (_is_huawei_ssh or hasattr(self, "password_change_prompt")): + return + if _is_huawei_telnet: + # Telnet answers Change-now inside telnet_login (below). + return + if not _is_huawei_ssh: + return + import re as _re + + # Huawei SSH: answer Change-now, wait for real ```` / ``[sysname]``. + # Do NOT use stock ``[\]>]`` — it matches ``]`` in ``[Y/N]``. + wait_pat = rf"(?:Change now|Please choose|{_REAL_PROMPT})" + data = self.read_until_pattern(pattern=wait_pat) + if _re.search(r"(?:Change now|Please choose)", data): + self.write_channel("N" + self.RETURN) + self.read_until_pattern(pattern=_REAL_PROMPT) + # Optional "security risks in the configuration file" (stock HuaweiSSH). + if _re.search( + r"security\srisks\sin\sthe\sconfiguration\sfile.*\[y\/n\]", + data, + flags=_re.I, + ): + try: + self.send_command("Y", expect_string=r"(?i)continue.*\[y\/n\]") + self.send_command("Y", expect_string=r"saved\ssuccessfully", read_timeout=60) + self.read_until_pattern(pattern=_REAL_PROMPT) + except Exception: + pass def _try_session_preparation(self, force_data: bool = True) -> None: # noqa: FBT001, FBT002 del force_data @@ -170,6 +203,55 @@ def _interactive_driver_class(base_cls: type) -> type: self.disconnect() raise + # Huawei Telnet: stock prompt_pattern ``[\]>]`` matches the ``]`` inside + # ``Change now? [Y/N]:``, so after sending N Netmiko can return before the + # Info banner / real ```` — live echo then looks like ``N`` + late MOTD. + if _is_huawei_telnet: + def telnet_login( # type: ignore[no-redef] + self, + pri_prompt_terminator: str = r"", + alt_prompt_terminator: str = r"", + username_pattern: str = r"(?:user:|username|login|user name)", + pwd_pattern: str = r"assword", + delay_factor: float = 1.0, + max_loops: int = 20, + ) -> str: + import re as _re + + from netmiko.exceptions import NetmikoAuthenticationException + + del pri_prompt_terminator, alt_prompt_terminator, delay_factor, max_loops + output = "" + return_msg = "" + try: + output = self.read_until_pattern(pattern=username_pattern, re_flags=_re.I) + return_msg += output + self.write_channel(self.username + self.TELNET_RETURN) + + output = self.read_until_pattern(pattern=pwd_pattern, re_flags=_re.I) + return_msg += output + assert self.password is not None + self.write_channel(self.password + "\r") + + wait_pat = rf"(?:Change now|Please choose|{_REAL_PROMPT})" + output = self.read_until_pattern(pattern=wait_pat) + return_msg += output + + if _re.search(r"(?:Change now|Please choose)", output): + self.write_channel("N" + self.TELNET_RETURN) + output = self.read_until_pattern(pattern=_REAL_PROMPT) + return_msg += output + return return_msg + if _re.search(_REAL_PROMPT, output): + return return_msg + raise EOFError + except EOFError: + assert self.remote_conn is not None + self.remote_conn.close() + raise NetmikoAuthenticationException(f"Login failed: {self.host}") + + _InteractiveSession.telnet_login = telnet_login # type: ignore[method-assign] + _InteractiveSession.__name__ = f"Interactive{getattr(base_cls, '__name__', 'Netmiko')}" return _InteractiveSession @@ -367,6 +449,9 @@ def _base_connect_kwargs( keepalive: int | None = None, ) -> dict[str, Any]: timeout = int(settings.ne_connect_timeout_sec or 30) + # Hop / long session_timeout paths need a roomier SSH handshake (busy VTY / MOTD). + if session_timeout is not None: + timeout = max(timeout, min(int(session_timeout), 60)) dev: dict[str, Any] = { "device_type": device_type, "host": host, @@ -538,14 +623,41 @@ 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 _looks_like_password_change_prompt(text: str) -> bool: + """Huawei/VRP ``Change now? [Y/N]:`` after successful password (not host-key).""" + tail = _auth_prompt_tail(text, lines=2) + if not tail: + return False + return bool( + re.search( + r"(?i)(change\s*now|please\s*choose|password\s+needs\s+to\s+be\s+changed).{0,80}:\s*$", + tail, + ) + ) + + +def _saw_huawei_last_login(text: str) -> bool: + return bool( + re.search( + r"(?is)(?:user\s+last\s+login\s+information|last\s+login\s+time|上次登录)", + str(text or ""), + ) + ) + + def _interactive_target_auth( conn: ConnectHandler, username: str, password: str, *, progress_cb: Any = None, + emit_raw: bool = True, ) -> 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. + + When ``emit_raw`` is False, device bytes are assumed to already reach the UI via + ``session_log`` ProgressBytesIO — only emit ``[netx]`` markers (avoids doubled echo). + """ from .ne_cli_errors import find_auth_failure_snippet # Hop stelnet can show Trying/Connected + two [Y/N] before password; keep budget generous. @@ -554,13 +666,15 @@ def _interactive_target_auth( sent_pass = False answered_continue = False answered_save_key = False + answered_pw_change = False empty_after_pass = 0 acc = "" while time.time() < deadline: buf = _read_channel(conn, wait=0.12, max_loops=12) if buf: acc += buf - _emit_progress(progress_cb, buf) + if emit_raw: + _emit_progress(progress_cb, buf) denied = find_auth_failure_snippet(acc) if denied: raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}") @@ -578,6 +692,15 @@ def _interactive_target_auth( answered_save_key = True continue + # Post-auth password-change must be answered or auth loops until timeout while + # the terminal already shows a near-login state (live echo) and stdin is blocked. + if sent_pass and not answered_pw_change and _looks_like_password_change_prompt(acc): + _emit_progress(progress_cb, "\r\n[netx] password-change → N\r\n") + _send_line(conn, "N") + answered_pw_change = True + empty_after_pass = 0 + 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") @@ -597,15 +720,20 @@ def _interactive_target_auth( raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}") if _looks_like_target_cli_prompt(acc): return + # Last-login banner already printed — hand off quickly even if prompt parse lags. + if _saw_huawei_last_login(acc) and empty_after_pass >= 1: + return # 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: + # Faster handoff: live echo already shows login; WS cannot accept stdin + # until open_netmiko_connection returns. + if empty_after_pass >= (2 if _saw_huawei_last_login(acc) else 3): return else: empty_after_pass = 0 - time.sleep(0.15) + time.sleep(0.12) continue if not buf.strip(): @@ -702,13 +830,16 @@ def _connect_via_cli_hop( keepalive=keepalive, ) _emit_progress(progress_cb, f"\r\n[netx] connecting hop {_hop_vendor(creds)} {hop_host}…\r\n") + # WebCRT passes ProgressBytesIO(session_log) that already tees device bytes to progress_cb. + # Re-emitting the same reads doubles every line (stelnet, Y/N, MOTD, prompts). + teed = isinstance(session_log, _ProgressBytesIO) 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.35) - if pre: + if pre and not teed: _emit_progress(progress_cb, pre) hop_prompt = extract_cli_prompt_marker(pre) if not hop_prompt: @@ -718,7 +849,7 @@ def _connect_via_cli_hop( except Exception: _send_line(conn, "") more = _read_channel(conn, wait=0.25, max_loops=12) - if more: + if more and not teed: _emit_progress(progress_cb, more) pre = pre + more hop_prompt = extract_cli_prompt_marker(pre) @@ -728,7 +859,8 @@ def _connect_via_cli_hop( for _ in range(6): more = _read_channel(conn, wait=0.3, max_loops=10) if more: - _emit_progress(progress_cb, more) + if not teed: + _emit_progress(progress_cb, more) pre += more hop_prompt = extract_cli_prompt_marker(pre) if hop_prompt: @@ -741,6 +873,7 @@ def _connect_via_cli_hop( str(creds["username"]), str(creds["password"]), progress_cb=progress_cb, + emit_raw=not teed, ) _attach_cli_hop_guard( conn, @@ -776,10 +909,11 @@ def _maybe_secondary_target_auth( *, progress_cb: Any = None, force: bool = False, + emit_raw: bool = True, ) -> 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: + if peek and emit_raw: _emit_progress(progress_cb, peek) need_user, need_pass = _prompt_needs_auth(peek) cont, save = _prompt_needs_host_key_confirm(peek) @@ -796,7 +930,13 @@ def _maybe_secondary_target_auth( 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) + _interactive_target_auth( + conn, + target_user, + target_pass, + progress_cb=progress_cb, + emit_raw=emit_raw, + ) def _connect_via_bastion( @@ -860,20 +1000,25 @@ def _connect_via_bastion( raise auth_mode = str(creds.get("hop_target_auth_mode") or "bastion_managed").strip().lower() + teed = isinstance(session_log, _ProgressBytesIO) 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) + _maybe_secondary_target_auth( + conn, creds, progress_cb=progress_cb, force=True, emit_raw=not teed + ) 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: + if peek and not teed: _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) + _maybe_secondary_target_auth( + conn, creds, progress_cb=progress_cb, force=False, emit_raw=not teed + ) except Exception: try: conn.disconnect() diff --git a/netx_api/webcrt_channel.py b/netx_api/webcrt_channel.py index 496a099..f9635fd 100644 --- a/netx_api/webcrt_channel.py +++ b/netx_api/webcrt_channel.py @@ -128,44 +128,90 @@ def _session_log_text(buf: io.BytesIO | None) -> str: return str(raw or "") -def _looks_like_cli_prompt(text: str) -> bool: - s = str(text or "").rstrip() - if not s: +def _cli_prompt_candidate_lines(text: str) -> list[str]: + """Non-empty transcript lines, stripping ANSI and ignoring trailing ``[netx]`` markers.""" + s = str(text or "").replace("\r\n", "\n").replace("\r", "\n") + s = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", s) + lines = [ln.strip() for ln in s.split("\n") if ln.strip()] + while lines and lines[-1].startswith("[netx]"): + lines.pop() + return lines + + +def _line_looks_like_cli_prompt(line: str) -> bool: + last = str(line or "").strip() + if not last: return False - # Buffer races can leave a stray ':' after Huawei ```` (from prior ``[Y/N]:``). - if s.endswith(":") and ">" in s: - s = s[:-1].rstrip() - # Common network CLI prompts: [HUAWEI] Router# Router> - return bool(re.search(r"(?:[>\]]|#)\s*$", s)) or bool(re.search(r"<[^>\r\n]+>\s*$", s)) + # Buffer races: ``:`` (stray from [Y/N]:) or ``N`` (password-change answer glued). + if last.endswith(":") and (">" in last or "]" in last): + last = last[:-1].rstrip() + if len(last) >= 2 and last[-1] in "NYny" and (last[-2] in ">]" or last.endswith(">")): + # ``N`` / ``[HW]Y`` after Change-now answer — treat as prompted. + last = last[:-1].rstrip() + return bool(re.search(r"(?:[>\]]|#)\s*$", last)) or bool(re.search(r"<[^>\r\n]+>\s*$", last)) + + +def _looks_like_cli_prompt(text: str) -> bool: + lines = _cli_prompt_candidate_lines(text) + if not lines: + return False + return _line_looks_like_cli_prompt(lines[-1]) def _looks_like_login_prompt(text: str) -> bool: """True when the transcript ends at Username:/Login:/Password: (interactive auth).""" - s = str(text or "").replace("\r\n", "\n").replace("\r", "\n") - lines = [ln.strip() for ln in s.split("\n") if ln.strip()] + lines = _cli_prompt_candidate_lines(text) if not lines: return False last = lines[-1] return bool(re.search(r"(?i)(user\s*name|login|password)\s*:\s*$", last)) +_PASSWORD_CHANGE_LINE_RE = re.compile( + r"(?i)(change\s*now|please\s*choose|password\s+needs\s+to\s+be\s+changed).{0,80}:\s*$" +) + + def _looks_like_password_change_prompt(text: str) -> bool: """Huawei/VRP post-auth ``Change now? [Y/N]:`` (Netmiko already answers N). Do not match bare ``[Y/N]:`` — stelnet host-key trust prompts share that suffix and are answered in ``_interactive_target_auth``, not by skipping Enter here. """ - s = str(text or "").replace("\r\n", "\n").replace("\r", "\n") - lines = [ln.strip() for ln in s.split("\n") if ln.strip()] + lines = _cli_prompt_candidate_lines(text) if not lines: return False - last = lines[-1] - return bool( - re.search( - r"(?i)(change\s*now|please\s*choose|password\s+needs\s+to\s+be\s+changed).{0,80}:\s*$", - last, - ) - ) + return bool(_PASSWORD_CHANGE_LINE_RE.search(lines[-1])) + + +def _password_change_still_pending(text: str) -> bool: + """True only when Change-now is still awaiting an answer. + + Netmiko ``HuaweiTelnet.telnet_login`` often already sent ``N`` before WebCRT + ``_finish_connect`` runs. Re-sending ``N`` lands as a command on ````. + Treat a lone ``N``/``Y`` echo (or a later CLI prompt) as already answered. + """ + lines = _cli_prompt_candidate_lines(text) + if not lines: + return False + last_change = -1 + for i, ln in enumerate(lines): + if _PASSWORD_CHANGE_LINE_RE.search(ln): + last_change = i + if last_change < 0: + return False + after = lines[last_change + 1 :] + if not after: + # Still sitting on Change now? [Y/N]: + return True + if any(_line_looks_like_cli_prompt(ln) for ln in after): + return False + # Device already echoed N/Y from Netmiko (or a prior answer) — do not send again. + if any(ln.strip().upper() in {"N", "Y"} for ln in after): + return False + # Banner / last-login text after Change-now without a prompt yet: Netmiko may still + # be draining; do not inject a second N (would race onto the prompt). + return False # Cisco/Netmiko often yields "R2#R2#" when a sync Enter is appended without a newline. diff --git a/netx_api/webcrt_router.py b/netx_api/webcrt_router.py index 8ae08d4..0247732 100644 --- a/netx_api/webcrt_router.py +++ b/netx_api/webcrt_router.py @@ -464,6 +464,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: loop = asyncio.get_running_loop() budget = max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 15 deadline = time.time() + budget + early_stdin: list[str] = [] async def _flush_connect_echo(cur_sess: Any) -> None: try: @@ -481,6 +482,50 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: except Exception: return + async def _drain_client_during_connect() -> bool: + """Handle ping/stdin/resize while connect runs. False = client gone.""" + nonlocal early_stdin + while True: + try: + msg_raw = await asyncio.wait_for(websocket.receive(), timeout=0.01) + except asyncio.TimeoutError: + return True + except Exception: + return False + if msg_raw.get("type") == "websocket.disconnect": + return False + raw = msg_raw.get("text") + if raw is None: + # Binary frames during connect: treat as stdin if decodeable. + if "bytes" in msg_raw and msg_raw["bytes"] is not None: + try: + early_stdin.append( + _decode_bytes(bytes(msg_raw["bytes"]), sess.encoding) + ) + except Exception: + pass + continue + try: + msg = json.loads(raw) + except json.JSONDecodeError: + early_stdin.append(str(raw)) + continue + mtype = str(msg.get("type") or "").strip().lower() + if mtype == "ping": + try: + await websocket.send_json({"type": "pong"}) + except Exception: + return False + elif mtype == "stdin": + data = msg.get("data") + if data is not None: + early_stdin.append(str(data)) + elif mtype == "resize": + # Ignore until ready (PTY size already set from create). + pass + elif mtype == "close": + return False + while True: cur = get_session(session_id) or sess if cur.state != "connecting": @@ -505,6 +550,14 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: # Client dropped during connect wait (StrictMode remount etc.) — keep PTY. return await _flush_connect_echo(cur) + if not await _drain_client_during_connect(): + return + # If connect finished while we drained client frames, exit promptly. + cur = get_session(session_id) or sess + if cur.state != "connecting": + await _flush_connect_echo(cur) + sess = cur + break remaining = deadline - time.time() if remaining <= 0: await websocket.send_json( @@ -512,7 +565,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: ) await websocket.close(code=4502) return - slice_timeout = min(0.35, max(0.15, remaining)) + slice_timeout = min(0.25, max(0.12, remaining)) try: await loop.run_in_executor( webcrt_io_executor(), @@ -545,6 +598,18 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: await websocket.close(code=4502) return + # Keystrokes typed while login was already on screen (before ready). + if early_stdin and sess is not None and not sess.closed and sess.conn is not None: + try: + await loop.run_in_executor( + webcrt_io_executor(), + sess.write_stdin, + "".join(early_stdin), + ) + except Exception: + _log.debug( + "webcrt early stdin flush failed session=%s", session_id, exc_info=True + ) # Session may have been deleted while the previous wait loop was exiting. if get_session(session_id) is None or sess.closed or sess.state in {"closed", "error"}: if sess.state == "error": @@ -560,6 +625,21 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None: pass return + # Drain any leftover connect-echo (e.g. WS attached after connect already ready). + try: + leftover_echo = sess.drain_connect_echo() + except Exception: + leftover_echo = "" + if leftover_echo: + raw = _encode_text(leftover_echo, getattr(sess, "encoding", None) or "utf-8") + try: + await websocket.send_bytes(raw) + except Exception: + try: + await websocket.send_json({"type": "stdout", "data": leftover_echo}) + except Exception: + pass + await websocket.send_json( { "type": "status", diff --git a/netx_api/webcrt_service.py b/netx_api/webcrt_service.py index c2056e8..fbe31fe 100644 --- a/netx_api/webcrt_service.py +++ b/netx_api/webcrt_service.py @@ -13,6 +13,7 @@ from .webcrt_channel import ( _looks_like_cli_prompt, _looks_like_login_prompt, _looks_like_password_change_prompt, + _password_change_still_pending, _normalize_encoding, _session_log_path, channel_return, @@ -56,6 +57,7 @@ __all__ = [ "_looks_like_cli_prompt", "_looks_like_login_prompt", "_looks_like_password_change_prompt", + "_password_change_still_pending", "_normalize_encoding", "_reap_sessions", "_session_log_path", diff --git a/netx_api/webcrt_session_registry.py b/netx_api/webcrt_session_registry.py index 1d0c123..2f73fde 100644 --- a/netx_api/webcrt_session_registry.py +++ b/netx_api/webcrt_session_registry.py @@ -4,6 +4,7 @@ from __future__ import annotations import io import logging import queue +import re import threading import time import uuid @@ -29,7 +30,7 @@ from .webcrt_channel import ( _is_prompt_only_echo, _looks_like_cli_prompt, _looks_like_login_prompt, - _looks_like_password_change_prompt, + _password_change_still_pending, _normalize_encoding, _prime_interactive_channel, _session_log_text, @@ -278,53 +279,97 @@ def _finish_connect( pre_log = _session_log_text(log_buf) # Pull post-auth banner/MOTD from the PTY. With interactive no-op session_preparation # (generic_termserver), Netmiko session_log is often empty — do not discard these bytes. + # Keep this short: live echo already streamed login; long settle blocks WS stdin. try: - early = _capture_raw_channel(conn, duration=0.35) + early = _capture_raw_channel(conn, duration=0.2) except Exception: early = "" if early: sess.push_connect_echo(early) seed = f"{pre_log}{early}" already_prompted = _looks_like_cli_prompt(seed) or _looks_like_cli_prompt(pre_log) + saw_password_change = bool( + re.search( + r"(?i)(change\s*now|please\s*choose|password\s+needs\s+to\s+be\s+changed).{0,80}:", + seed, + ) + ) primed = "" - # Auto-answer Huawei post-login password-change only if still sitting on the prompt - # (Netmiko telnet_login usually already answered N — do not send a second N/Enter). - if _looks_like_password_change_prompt(seed) and not already_prompted: + # Huawei Telnet/SSH interactive drivers already answer Change-now during login. + # Never send a second N here — it lands as ``N`` / Unrecognized command. + netmiko_handles_pw_change = any( + getattr(c, "__name__", "") in {"HuaweiTelnet", "HuaweiSSH", "HuaweiVrpv8SSH"} + for c in type(conn).__mro__ + ) + if ( + (not netmiko_handles_pw_change) + and _password_change_still_pending(seed) + and not already_prompted + ): + # Brief peek — another path may have answered while session_log still ends on Change-now. 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) + peek = _capture_raw_channel(conn, duration=0.35) except Exception: - primed = "" + peek = "" + if peek: + sess.push_connect_echo(peek) + seed = f"{seed}{peek}" + already_prompted = _looks_like_cli_prompt(seed) + if _password_change_still_pending(seed) and not already_prompted: + 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.6) + 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) + primed = _capture_raw_channel(conn, duration=0.45) if primed: sess.push_connect_echo(primed) except Exception: primed = "" - else: + elif not already_prompted and not saw_password_change: + # Only nudge Enter when we do not already have a CLI prompt (avoids duplicate + # prompts and delays ready while the user can already see login via live echo). + # After Huawei Change-now, Netmiko already drove login — Enter here reorders MOTD. try: - primed = _prime_interactive_channel(conn, already_prompted=already_prompted) + primed = _prime_interactive_channel(conn, already_prompted=False) + if primed: + sess.push_connect_echo(primed) + except Exception: + primed = "" + elif not already_prompted and saw_password_change: + # Drain MOTD/prompt only — never Enter (would glue onto or reprint prompt). + try: + primed = _capture_raw_channel(conn, duration=0.45) if primed: sess.push_connect_echo(primed) except Exception: primed = "" combined = f"{seed}{primed}" - # Final settle: keep stragglers (normalize collapses duplicate prompts when bootstrapping). + # Brief settle only — do not burn seconds here while the terminal shows a prompt. + settle_sec = 0.12 if already_prompted or _looks_like_cli_prompt(combined) else 0.3 try: - more = _capture_raw_channel(conn, duration=0.35) + more = _capture_raw_channel(conn, duration=settle_sec) if more: - sess.push_connect_echo(more) - combined += more + # Drop a second ```` that often races in after password-change login. + hint = "" + for ln in reversed(str(combined).replace("\r\n", "\n").split("\n")): + if _looks_like_cli_prompt(ln): + hint = ln.strip() + break + if not (hint and _is_prompt_only_echo(more, hint)): + 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) + combined = _drain_channel(conn, rounds=4, wait=0.06) if combined: sess.push_connect_echo(combined) except Exception: @@ -334,22 +379,38 @@ def _finish_connect( # Replaying prepare_bootstrap_output(combined) caused a full duplicate login. echoed = sess.connect_echo_text() bootstrap = "" + # Prefer last real prompt line as hint so we can drop duplicate prompt leftovers. + prompt_hint = "" + if echoed or combined: + for ln in reversed( + str(echoed or combined).replace("\r\n", "\n").replace("\r", "\n").split("\n") + ): + if _looks_like_cli_prompt(ln): + prompt_hint = ln.strip() + break if not echoed.strip(): bootstrap = prepare_bootstrap_output(combined) try: - leftover = _capture_raw_channel(conn, duration=0.12) + leftover = _capture_raw_channel(conn, duration=0.1) except Exception: leftover = "" - if leftover and leftover.strip() not in {":", ">", "#", "]", "$"}: + if ( + leftover + and leftover.strip() not in {":", ">", "#", "]", "$"} + and not _is_prompt_only_echo(leftover, prompt_hint) + ): sess.push_connect_echo(leftover) bootstrap = prepare_bootstrap_output(f"{bootstrap}{leftover}") else: - # Optional unseen tail only (already pushed to connect-echo above when present). try: - leftover = _capture_raw_channel(conn, duration=0.12) + leftover = _capture_raw_channel(conn, duration=0.08) except Exception: leftover = "" - if leftover and leftover.strip() not in {":", ">", "#", "]", "$"}: + if ( + leftover + and leftover.strip() not in {":", ">", "#", "]", "$"} + and not _is_prompt_only_echo(leftover, prompt_hint) + ): sess.push_connect_echo(leftover) hop_guard = get_cli_hop_guard(conn) @@ -360,8 +421,11 @@ def _finish_connect( # 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 = echoed or bootstrap + # After Huawei Change-now, never send a sync Enter on WS attach (reorders MOTD / glues N). sess.needs_live_prompt = ( - not _looks_like_cli_prompt(final_view) and not _looks_like_login_prompt(final_view) + not saw_password_change + and not _looks_like_cli_prompt(final_view) + and not _looks_like_login_prompt(final_view) ) sess.open_session_log() log_seed = echoed or bootstrap @@ -369,10 +433,9 @@ def _finish_connect( 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 final_view: + if not prompt_hint and 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 + settle_deadline = time.time() + (0.2 if already_prompted or saw_password_change else 0.35) while time.time() < settle_deadline: try: chunk = sess.out_queue.get_nowait() @@ -395,12 +458,39 @@ def _finish_connect( sess.run_post_login_commands() except Exception: _log.debug("post_login failed session=%s", sess.session_id, exc_info=True) - # Same SSH transport: open SFTP channel when the device supports it. - sftp_ok = sess.try_attach_sftp() + + # Mark ready BEFORE SFTP. Opening an SFTP channel on some Huawei SSH boxes can + # block for a long time; meanwhile live echo already showed login and the WS + # wait loop still rejects stdin — looks like "logged in but cannot type", then + # connect_timeout / proxy 1011. sess.state = "ready" sess.connect_finished_at = time.time() sess._ready_event.set() elapsed_ms = int((sess.connect_finished_at - sess.connect_started_at) * 1000) + + def _probe_sftp() -> None: + try: + ok = bool(sess.try_attach_sftp()) + if ok: + _audit( + "session_sftp_ready", + session_id=sess.session_id, + ne_id=sess.ne_id, + ne_ip=sess.ne_ip, + client=client or "", + ) + except Exception: + _log.debug("deferred sftp probe failed session=%s", sess.session_id, exc_info=True) + + if str(sess.protocol or "ssh").lower() == "ssh" and not hop_guard: + threading.Thread( + target=_probe_sftp, + name=f"webcrt-sftp-{sess.session_id[:8]}", + daemon=True, + ).start() + else: + sess.sftp_ready = False + _audit( "session_created", session_id=sess.session_id, @@ -414,7 +504,7 @@ def _finish_connect( hop_vendor=str(creds.get("hop_vendor") or "") if creds.get("hop_enabled") else "", cli_hop_guard=bool(hop_guard), cli_hop_prompt=str((hop_guard or {}).get("hop_prompt") or ""), - sftp_ready=bool(sftp_ok), + sftp_ready=bool(sess.sftp_ready), client=client or "", connect_ms=elapsed_ms, active=active_session_count(), diff --git a/tests/test_bastion_hop.py b/tests/test_bastion_hop.py index 81e85de..de02487 100644 --- a/tests/test_bastion_hop.py +++ b/tests/test_bastion_hop.py @@ -280,7 +280,9 @@ class BastionConnectImplTests(unittest.TestCase): password="bastion-pass", timeout=unittest.mock.ANY, ) - interact.assert_called_once_with(conn, "target-user", "target-pass", progress_cb=None) + interact.assert_called_once_with( + conn, "target-user", "target-pass", progress_cb=None, emit_raw=True + ) class BastionInteractiveHandlerTests(unittest.TestCase): diff --git a/tests/test_cli_hop_target_auth.py b/tests/test_cli_hop_target_auth.py index a482f6e..ecf8008 100644 --- a/tests/test_cli_hop_target_auth.py +++ b/tests/test_cli_hop_target_auth.py @@ -131,6 +131,30 @@ class HuaweiStelnetAuthTests(unittest.TestCase): sent = [c.args[1] for c in mock_send.call_args_list] self.assertEqual(sent, ["ipran", "Y", "N", "secret"]) + @patch("netx_api.ne_session_connect._read_channel") + @patch("netx_api.ne_session_connect._send_line") + def test_answers_password_change_after_login( + self, + mock_send: MagicMock, + mock_read: MagicMock, + ) -> None: + """Huawei ``Change now? [Y/N]:`` after password must not hang auth until timeout.""" + chunks = [ + "Please input the username:", + "Enter password:", + "Change now? [Y/N]:", + "\n", + ] + mock_read.side_effect = lambda *_a, **_k: chunks.pop(0) if chunks else "" + conn = MagicMock() + seen: list[str] = [] + _interactive_target_auth(conn, "admin", "secret", progress_cb=seen.append) + self.assertEqual( + [c.args[1] for c in mock_send.call_args_list], + ["admin", "secret", "N"], + ) + self.assertTrue(any("password-change" in p for p in seen)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_webcrt.py b/tests/test_webcrt.py index 8ac2a35..e63093c 100644 --- a/tests/test_webcrt.py +++ b/tests/test_webcrt.py @@ -108,9 +108,27 @@ class WebcrtServiceTests(unittest.TestCase): self.assertTrue(svc._looks_like_cli_prompt("")) # Stray ':' after Huawei prompt must still count as prompted (no extra Enter). self.assertTrue(svc._looks_like_cli_prompt(":")) + self.assertTrue(svc._looks_like_cli_prompt("N")) + # Trailing [netx] progress lines must not hide an already-visible CLI prompt. + self.assertTrue(svc._looks_like_cli_prompt("\r\n[netx] password-change → N\r\n")) self.assertEqual(svc.prepare_bootstrap_output("banner\n:"), "banner\n") self.assertTrue(svc._looks_like_password_change_prompt("Change now? [Y/N]:")) self.assertFalse(svc._looks_like_password_change_prompt("Change now? [Y/N]:N")) + # Still pending only while sitting on Change-now with no answer/prompt after. + self.assertTrue(svc._password_change_still_pending("Change now? [Y/N]:")) + # Netmiko already sent N — do not send a second N (would become N). + self.assertFalse( + svc._password_change_still_pending( + "Change now? [Y/N]:\nN\nInfo: VTY\n" + ) + ) + self.assertFalse(svc._password_change_still_pending("Change now? [Y/N]:\nN")) + self.assertFalse( + svc._password_change_still_pending( + "The password needs to be changed. Change now? [Y/N]:\n" + "Info: The max number of VTY users is 5\n" + ) + ) # Bare / stelnet host-key [Y/N] must not be treated as password-change. self.assertFalse(svc._looks_like_password_change_prompt("[Y/N]:")) self.assertFalse( @@ -173,9 +191,10 @@ class WebcrtServiceTests(unittest.TestCase): ) sess = svc.get_session(out["session_id"]) assert sess is not None - boot = sess.bootstrap_output.decode("utf-8", errors="replace") - self.assertIn("****", boot) - self.assertIn("R2#", boot) + # Live connect-echo owns the login transcript (bootstrap stays empty to avoid double play). + echo = sess.connect_echo_text() + self.assertIn("****", echo) + self.assertIn("R2#", echo) svc.close_session(out["session_id"], reason="test") @patch.object(reg, "_audit") @@ -318,9 +337,9 @@ class WebcrtServiceTests(unittest.TestCase): self.assertFalse(out.get("cli_hop")) # bastion hop is not vendor CLI hop guard sess = svc.get_session(out["session_id"]) assert sess is not None - boot = sess.bootstrap_output.decode("utf-8", errors="replace") - self.assertIn("Username:huawei", boot) - self.assertIn("", boot) + echo = sess.connect_echo_text() + self.assertIn("Username:huawei", echo) + self.assertIn("", echo) self.assertFalse(sess.needs_live_prompt) before = list(fake.written) sess.write_stdin("\n")