mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
Fix Huawei WebCRT hop login echo doubling and safer Change-now handling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cacd8c7b75
commit
0a550abcb0
9 changed files with 498 additions and 77 deletions
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue