Fix Huawei WebCRT hop login echo doubling and safer Change-now handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-28 23:34:15 +08:00
parent cacd8c7b75
commit 0a550abcb0
9 changed files with 498 additions and 77 deletions

View file

@ -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 = ""

View file

@ -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>`` / ``[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 ``<r1>`` — live echo then looks like ``<r1>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()

View file

@ -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 ``<r1>`` (from prior ``[Y/N]:``).
if s.endswith(":") and ">" in s:
s = s[:-1].rstrip()
# Common network CLI prompts: <r1> [HUAWEI] Router# Router>
return bool(re.search(r"(?:[>\]]|#)\s*$", s)) or bool(re.search(r"<[^>\r\n]+>\s*$", s))
# Buffer races: ``<r1>:`` (stray from [Y/N]:) or ``<r1>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(">")):
# ``<r1>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 ``<r1>``.
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.

View file

@ -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",

View file

@ -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",

View file

@ -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 ``<r1>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 <r1> 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 ``<r1>`` 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(),

View file

@ -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):

View file

@ -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]:",
"<HW-TARGET>\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()

View file

@ -108,9 +108,27 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertTrue(svc._looks_like_cli_prompt("<r1>"))
# Stray ':' after Huawei prompt must still count as prompted (no extra Enter).
self.assertTrue(svc._looks_like_cli_prompt("<r1>:"))
self.assertTrue(svc._looks_like_cli_prompt("<r1>N"))
# Trailing [netx] progress lines must not hide an already-visible CLI prompt.
self.assertTrue(svc._looks_like_cli_prompt("<HW>\r\n[netx] password-change → N\r\n"))
self.assertEqual(svc.prepare_bootstrap_output("banner\n<r1>:"), "banner\n<r1>")
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 <r1>N).
self.assertFalse(
svc._password_change_still_pending(
"Change now? [Y/N]:\nN\nInfo: VTY\n<r1>"
)
)
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<r1>"
)
)
# 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("<r1>", boot)
echo = sess.connect_echo_text()
self.assertIn("Username:huawei", echo)
self.assertIn("<r1>", echo)
self.assertFalse(sess.needs_live_prompt)
before = list(fake.written)
sess.write_stdin("\n")