mirror of
https://github.com/hansjone/netx.git
synced 2026-10-11 14:12:51 +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
|
return "target_auth_failed: " + detail
|
||||||
if "timed out" in raw or "timeout" in raw or "hop_connect_failed" in raw:
|
if "timed out" in raw or "timeout" in raw or "hop_connect_failed" in raw:
|
||||||
return "hop_connect_failed: " + detail
|
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 hop_v in ("linux", "bastion"):
|
||||||
if "vault" in raw or "bastion" in raw:
|
if "vault" in raw or "bastion" in raw:
|
||||||
return "bastion_auth_failed: " + detail
|
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
|
session_timeout = 180 if creds.get("hop_enabled") else None
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
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 "")
|
prompt = str(conn.find_prompt() or "")
|
||||||
command = hostname_probe_command(creds["device_type"], vendor)
|
command = hostname_probe_command(creds["device_type"], vendor)
|
||||||
output = ""
|
output = ""
|
||||||
|
|
|
||||||
|
|
@ -142,13 +142,18 @@ def _netmiko_driver_class(device_type: str) -> type:
|
||||||
def _interactive_driver_class(base_cls: type) -> type:
|
def _interactive_driver_class(base_cls: type) -> type:
|
||||||
"""Subclass for WebCRT: raw interactive PTY after transport auth (SecureCRT-like).
|
"""Subclass for WebCRT: raw interactive PTY after transport auth (SecureCRT-like).
|
||||||
|
|
||||||
Skips Netmiko session prep (prompt discovery, terminal length/width, force RETURN)
|
Skips Netmiko session prep (prompt discovery, terminal length/width, force RETURN).
|
||||||
and Huawei ``special_login_handler`` (read_until prompt / password-change). Those
|
Huawei password-change is handled with a *safe* prompt pattern (never bare ``]``,
|
||||||
waits are why WebCRT stuck on ``waiting_prompt`` while connectivity probes succeed:
|
which matches ``[Y/N]`` and scrambles login).
|
||||||
collection still runs full Netmiko login; WebCRT must leave the PTY for live echo
|
|
||||||
and hop secondary auth instead.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_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]
|
class _InteractiveSession(base_cls): # type: ignore[misc,valid-type]
|
||||||
def disable_paging(self, *args: Any, **kwargs: Any) -> str: # noqa: ANN401
|
def disable_paging(self, *args: Any, **kwargs: Any) -> str: # noqa: ANN401
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -160,7 +165,35 @@ def _interactive_driver_class(base_cls: type) -> type:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def special_login_handler(self, delay_factor: float = 1.0) -> None: # noqa: ARG002
|
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
|
def _try_session_preparation(self, force_data: bool = True) -> None: # noqa: FBT001, FBT002
|
||||||
del force_data
|
del force_data
|
||||||
|
|
@ -170,6 +203,55 @@ def _interactive_driver_class(base_cls: type) -> type:
|
||||||
self.disconnect()
|
self.disconnect()
|
||||||
raise
|
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')}"
|
_InteractiveSession.__name__ = f"Interactive{getattr(base_cls, '__name__', 'Netmiko')}"
|
||||||
return _InteractiveSession
|
return _InteractiveSession
|
||||||
|
|
||||||
|
|
@ -367,6 +449,9 @@ def _base_connect_kwargs(
|
||||||
keepalive: int | None = None,
|
keepalive: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
timeout = int(settings.ne_connect_timeout_sec or 30)
|
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] = {
|
dev: dict[str, Any] = {
|
||||||
"device_type": device_type,
|
"device_type": device_type,
|
||||||
"host": host,
|
"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))
|
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(
|
def _interactive_target_auth(
|
||||||
conn: ConnectHandler,
|
conn: ConnectHandler,
|
||||||
username: str,
|
username: str,
|
||||||
password: str,
|
password: str,
|
||||||
*,
|
*,
|
||||||
progress_cb: Any = None,
|
progress_cb: Any = None,
|
||||||
|
emit_raw: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Respond to username/password (and Huawei stelnet host-key) prompts after hop command."""
|
"""Respond to username/password (and Huawei stelnet host-key) prompts after hop command.
|
||||||
|
|
||||||
|
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
|
from .ne_cli_errors import find_auth_failure_snippet
|
||||||
|
|
||||||
# Hop stelnet can show Trying/Connected + two [Y/N] before password; keep budget generous.
|
# Hop stelnet can show Trying/Connected + two [Y/N] before password; keep budget generous.
|
||||||
|
|
@ -554,13 +666,15 @@ def _interactive_target_auth(
|
||||||
sent_pass = False
|
sent_pass = False
|
||||||
answered_continue = False
|
answered_continue = False
|
||||||
answered_save_key = False
|
answered_save_key = False
|
||||||
|
answered_pw_change = False
|
||||||
empty_after_pass = 0
|
empty_after_pass = 0
|
||||||
acc = ""
|
acc = ""
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
buf = _read_channel(conn, wait=0.12, max_loops=12)
|
buf = _read_channel(conn, wait=0.12, max_loops=12)
|
||||||
if buf:
|
if buf:
|
||||||
acc += buf
|
acc += buf
|
||||||
_emit_progress(progress_cb, buf)
|
if emit_raw:
|
||||||
|
_emit_progress(progress_cb, buf)
|
||||||
denied = find_auth_failure_snippet(acc)
|
denied = find_auth_failure_snippet(acc)
|
||||||
if denied:
|
if denied:
|
||||||
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
|
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
|
||||||
|
|
@ -578,6 +692,15 @@ def _interactive_target_auth(
|
||||||
answered_save_key = True
|
answered_save_key = True
|
||||||
continue
|
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)
|
need_user, need_pass = _prompt_needs_auth(acc)
|
||||||
if need_pass and not sent_pass:
|
if need_pass and not sent_pass:
|
||||||
_emit_progress(progress_cb, "\r\n[netx] sending password\r\n")
|
_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}")
|
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
|
||||||
if _looks_like_target_cli_prompt(acc):
|
if _looks_like_target_cli_prompt(acc):
|
||||||
return
|
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 —
|
# Do not treat a single empty read right after password as success —
|
||||||
# Huawei still prints last-login banner / prompt.
|
# Huawei still prints last-login banner / prompt.
|
||||||
if not buf.strip():
|
if not buf.strip():
|
||||||
empty_after_pass += 1
|
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
|
return
|
||||||
else:
|
else:
|
||||||
empty_after_pass = 0
|
empty_after_pass = 0
|
||||||
time.sleep(0.15)
|
time.sleep(0.12)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not buf.strip():
|
if not buf.strip():
|
||||||
|
|
@ -702,13 +830,16 @@ def _connect_via_cli_hop(
|
||||||
keepalive=keepalive,
|
keepalive=keepalive,
|
||||||
)
|
)
|
||||||
_emit_progress(progress_cb, f"\r\n[netx] connecting hop {_hop_vendor(creds)} {hop_host}…\r\n")
|
_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)
|
conn = _build_netmiko_connection(hop_dev, interactive=interactive)
|
||||||
try:
|
try:
|
||||||
# MUST resize before stelnet/telnet — nested session captures hop TTY size at start
|
# MUST resize before stelnet/telnet — nested session captures hop TTY size at start
|
||||||
# and often ignores later WINCH. Wrong width → mid-line edit redraw wraps in WebCRT.
|
# and often ignores later WINCH. Wrong width → mid-line edit redraw wraps in WebCRT.
|
||||||
_resize_pty(conn, cols, rows)
|
_resize_pty(conn, cols, rows)
|
||||||
pre = _read_channel(conn, wait=0.35)
|
pre = _read_channel(conn, wait=0.35)
|
||||||
if pre:
|
if pre and not teed:
|
||||||
_emit_progress(progress_cb, pre)
|
_emit_progress(progress_cb, pre)
|
||||||
hop_prompt = extract_cli_prompt_marker(pre)
|
hop_prompt = extract_cli_prompt_marker(pre)
|
||||||
if not hop_prompt:
|
if not hop_prompt:
|
||||||
|
|
@ -718,7 +849,7 @@ def _connect_via_cli_hop(
|
||||||
except Exception:
|
except Exception:
|
||||||
_send_line(conn, "")
|
_send_line(conn, "")
|
||||||
more = _read_channel(conn, wait=0.25, max_loops=12)
|
more = _read_channel(conn, wait=0.25, max_loops=12)
|
||||||
if more:
|
if more and not teed:
|
||||||
_emit_progress(progress_cb, more)
|
_emit_progress(progress_cb, more)
|
||||||
pre = pre + more
|
pre = pre + more
|
||||||
hop_prompt = extract_cli_prompt_marker(pre)
|
hop_prompt = extract_cli_prompt_marker(pre)
|
||||||
|
|
@ -728,7 +859,8 @@ def _connect_via_cli_hop(
|
||||||
for _ in range(6):
|
for _ in range(6):
|
||||||
more = _read_channel(conn, wait=0.3, max_loops=10)
|
more = _read_channel(conn, wait=0.3, max_loops=10)
|
||||||
if more:
|
if more:
|
||||||
_emit_progress(progress_cb, more)
|
if not teed:
|
||||||
|
_emit_progress(progress_cb, more)
|
||||||
pre += more
|
pre += more
|
||||||
hop_prompt = extract_cli_prompt_marker(pre)
|
hop_prompt = extract_cli_prompt_marker(pre)
|
||||||
if hop_prompt:
|
if hop_prompt:
|
||||||
|
|
@ -741,6 +873,7 @@ def _connect_via_cli_hop(
|
||||||
str(creds["username"]),
|
str(creds["username"]),
|
||||||
str(creds["password"]),
|
str(creds["password"]),
|
||||||
progress_cb=progress_cb,
|
progress_cb=progress_cb,
|
||||||
|
emit_raw=not teed,
|
||||||
)
|
)
|
||||||
_attach_cli_hop_guard(
|
_attach_cli_hop_guard(
|
||||||
conn,
|
conn,
|
||||||
|
|
@ -776,10 +909,11 @@ def _maybe_secondary_target_auth(
|
||||||
*,
|
*,
|
||||||
progress_cb: Any = None,
|
progress_cb: Any = None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
|
emit_raw: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run interactive target auth when the PTY still shows login / host-key prompts."""
|
"""Run interactive target auth when the PTY still shows login / host-key prompts."""
|
||||||
peek = _read_channel(conn, wait=0.45, max_loops=14)
|
peek = _read_channel(conn, wait=0.45, max_loops=14)
|
||||||
if peek:
|
if peek and emit_raw:
|
||||||
_emit_progress(progress_cb, peek)
|
_emit_progress(progress_cb, peek)
|
||||||
need_user, need_pass = _prompt_needs_auth(peek)
|
need_user, need_pass = _prompt_needs_auth(peek)
|
||||||
cont, save = _prompt_needs_host_key_confirm(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:
|
if not target_user and need_user:
|
||||||
_emit_progress(progress_cb, "\r\n[netx] username prompt but target username empty\r\n")
|
_emit_progress(progress_cb, "\r\n[netx] username prompt but target username empty\r\n")
|
||||||
return
|
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(
|
def _connect_via_bastion(
|
||||||
|
|
@ -860,20 +1000,25 @@ def _connect_via_bastion(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
auth_mode = str(creds.get("hop_target_auth_mode") or "bastion_managed").strip().lower()
|
auth_mode = str(creds.get("hop_target_auth_mode") or "bastion_managed").strip().lower()
|
||||||
|
teed = isinstance(session_log, _ProgressBytesIO)
|
||||||
try:
|
try:
|
||||||
if auth_mode == "manual":
|
if auth_mode == "manual":
|
||||||
target_pass = str(creds.get("password") or "")
|
target_pass = str(creds.get("password") or "")
|
||||||
if target_pass:
|
if target_pass:
|
||||||
_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:
|
else:
|
||||||
# Still drain banner so WebCRT live echo shows what the proxy printed.
|
# Still drain banner so WebCRT live echo shows what the proxy printed.
|
||||||
peek = _read_channel(conn, wait=0.4, max_loops=12)
|
peek = _read_channel(conn, wait=0.4, max_loops=12)
|
||||||
if peek:
|
if peek and not teed:
|
||||||
_emit_progress(progress_cb, peek)
|
_emit_progress(progress_cb, peek)
|
||||||
else:
|
else:
|
||||||
# bastion_managed: normally no secondary auth, but if the proxy still presents
|
# bastion_managed: normally no secondary auth, but if the proxy still presents
|
||||||
# Username/Password or Huawei host-key prompts, answer them when creds exist.
|
# 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:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
conn.disconnect()
|
conn.disconnect()
|
||||||
|
|
|
||||||
|
|
@ -128,44 +128,90 @@ def _session_log_text(buf: io.BytesIO | None) -> str:
|
||||||
return str(raw or "")
|
return str(raw or "")
|
||||||
|
|
||||||
|
|
||||||
def _looks_like_cli_prompt(text: str) -> bool:
|
def _cli_prompt_candidate_lines(text: str) -> list[str]:
|
||||||
s = str(text or "").rstrip()
|
"""Non-empty transcript lines, stripping ANSI and ignoring trailing ``[netx]`` markers."""
|
||||||
if not s:
|
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
|
return False
|
||||||
# Buffer races can leave a stray ':' after Huawei ``<r1>`` (from prior ``[Y/N]:``).
|
# Buffer races: ``<r1>:`` (stray from [Y/N]:) or ``<r1>N`` (password-change answer glued).
|
||||||
if s.endswith(":") and ">" in s:
|
if last.endswith(":") and (">" in last or "]" in last):
|
||||||
s = s[:-1].rstrip()
|
last = last[:-1].rstrip()
|
||||||
# Common network CLI prompts: <r1> [HUAWEI] Router# Router>
|
if len(last) >= 2 and last[-1] in "NYny" and (last[-2] in ">]" or last.endswith(">")):
|
||||||
return bool(re.search(r"(?:[>\]]|#)\s*$", s)) or bool(re.search(r"<[^>\r\n]+>\s*$", s))
|
# ``<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:
|
def _looks_like_login_prompt(text: str) -> bool:
|
||||||
"""True when the transcript ends at Username:/Login:/Password: (interactive auth)."""
|
"""True when the transcript ends at Username:/Login:/Password: (interactive auth)."""
|
||||||
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
|
lines = _cli_prompt_candidate_lines(text)
|
||||||
lines = [ln.strip() for ln in s.split("\n") if ln.strip()]
|
|
||||||
if not lines:
|
if not lines:
|
||||||
return False
|
return False
|
||||||
last = lines[-1]
|
last = lines[-1]
|
||||||
return bool(re.search(r"(?i)(user\s*name|login|password)\s*:\s*$", last))
|
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:
|
def _looks_like_password_change_prompt(text: str) -> bool:
|
||||||
"""Huawei/VRP post-auth ``Change now? [Y/N]:`` (Netmiko already answers N).
|
"""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
|
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.
|
and are answered in ``_interactive_target_auth``, not by skipping Enter here.
|
||||||
"""
|
"""
|
||||||
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
|
lines = _cli_prompt_candidate_lines(text)
|
||||||
lines = [ln.strip() for ln in s.split("\n") if ln.strip()]
|
|
||||||
if not lines:
|
if not lines:
|
||||||
return False
|
return False
|
||||||
last = lines[-1]
|
return bool(_PASSWORD_CHANGE_LINE_RE.search(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*$",
|
def _password_change_still_pending(text: str) -> bool:
|
||||||
last,
|
"""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.
|
# 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()
|
loop = asyncio.get_running_loop()
|
||||||
budget = max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 15
|
budget = max(30, int(settings.webcrt_connect_timeout_sec or 90)) + 15
|
||||||
deadline = time.time() + budget
|
deadline = time.time() + budget
|
||||||
|
early_stdin: list[str] = []
|
||||||
|
|
||||||
async def _flush_connect_echo(cur_sess: Any) -> None:
|
async def _flush_connect_echo(cur_sess: Any) -> None:
|
||||||
try:
|
try:
|
||||||
|
|
@ -481,6 +482,50 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
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:
|
while True:
|
||||||
cur = get_session(session_id) or sess
|
cur = get_session(session_id) or sess
|
||||||
if cur.state != "connecting":
|
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.
|
# Client dropped during connect wait (StrictMode remount etc.) — keep PTY.
|
||||||
return
|
return
|
||||||
await _flush_connect_echo(cur)
|
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()
|
remaining = deadline - time.time()
|
||||||
if remaining <= 0:
|
if remaining <= 0:
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
|
|
@ -512,7 +565,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
)
|
)
|
||||||
await websocket.close(code=4502)
|
await websocket.close(code=4502)
|
||||||
return
|
return
|
||||||
slice_timeout = min(0.35, max(0.15, remaining))
|
slice_timeout = min(0.25, max(0.12, remaining))
|
||||||
try:
|
try:
|
||||||
await loop.run_in_executor(
|
await loop.run_in_executor(
|
||||||
webcrt_io_executor(),
|
webcrt_io_executor(),
|
||||||
|
|
@ -545,6 +598,18 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
await websocket.close(code=4502)
|
await websocket.close(code=4502)
|
||||||
return
|
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.
|
# 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 get_session(session_id) is None or sess.closed or sess.state in {"closed", "error"}:
|
||||||
if sess.state == "error":
|
if sess.state == "error":
|
||||||
|
|
@ -560,6 +625,21 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
pass
|
pass
|
||||||
return
|
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(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
"type": "status",
|
"type": "status",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from .webcrt_channel import (
|
||||||
_looks_like_cli_prompt,
|
_looks_like_cli_prompt,
|
||||||
_looks_like_login_prompt,
|
_looks_like_login_prompt,
|
||||||
_looks_like_password_change_prompt,
|
_looks_like_password_change_prompt,
|
||||||
|
_password_change_still_pending,
|
||||||
_normalize_encoding,
|
_normalize_encoding,
|
||||||
_session_log_path,
|
_session_log_path,
|
||||||
channel_return,
|
channel_return,
|
||||||
|
|
@ -56,6 +57,7 @@ __all__ = [
|
||||||
"_looks_like_cli_prompt",
|
"_looks_like_cli_prompt",
|
||||||
"_looks_like_login_prompt",
|
"_looks_like_login_prompt",
|
||||||
"_looks_like_password_change_prompt",
|
"_looks_like_password_change_prompt",
|
||||||
|
"_password_change_still_pending",
|
||||||
"_normalize_encoding",
|
"_normalize_encoding",
|
||||||
"_reap_sessions",
|
"_reap_sessions",
|
||||||
"_session_log_path",
|
"_session_log_path",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -29,7 +30,7 @@ from .webcrt_channel import (
|
||||||
_is_prompt_only_echo,
|
_is_prompt_only_echo,
|
||||||
_looks_like_cli_prompt,
|
_looks_like_cli_prompt,
|
||||||
_looks_like_login_prompt,
|
_looks_like_login_prompt,
|
||||||
_looks_like_password_change_prompt,
|
_password_change_still_pending,
|
||||||
_normalize_encoding,
|
_normalize_encoding,
|
||||||
_prime_interactive_channel,
|
_prime_interactive_channel,
|
||||||
_session_log_text,
|
_session_log_text,
|
||||||
|
|
@ -278,53 +279,97 @@ def _finish_connect(
|
||||||
pre_log = _session_log_text(log_buf)
|
pre_log = _session_log_text(log_buf)
|
||||||
# Pull post-auth banner/MOTD from the PTY. With interactive no-op session_preparation
|
# 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.
|
# (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:
|
try:
|
||||||
early = _capture_raw_channel(conn, duration=0.35)
|
early = _capture_raw_channel(conn, duration=0.2)
|
||||||
except Exception:
|
except Exception:
|
||||||
early = ""
|
early = ""
|
||||||
if early:
|
if early:
|
||||||
sess.push_connect_echo(early)
|
sess.push_connect_echo(early)
|
||||||
seed = f"{pre_log}{early}"
|
seed = f"{pre_log}{early}"
|
||||||
already_prompted = _looks_like_cli_prompt(seed) or _looks_like_cli_prompt(pre_log)
|
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 = ""
|
primed = ""
|
||||||
# Auto-answer Huawei post-login password-change only if still sitting on the prompt
|
# Huawei Telnet/SSH interactive drivers already answer Change-now during login.
|
||||||
# (Netmiko telnet_login usually already answered N — do not send a second N/Enter).
|
# Never send a second N here — it lands as ``<r1>N`` / Unrecognized command.
|
||||||
if _looks_like_password_change_prompt(seed) and not already_prompted:
|
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:
|
try:
|
||||||
conn.write_channel("N" + (getattr(conn, "RETURN", None) or "\n"))
|
peek = _capture_raw_channel(conn, duration=0.35)
|
||||||
sess.push_connect_echo("\r\n[netx] password-change → N\r\n")
|
|
||||||
primed = _capture_raw_channel(conn, duration=0.9)
|
|
||||||
if primed:
|
|
||||||
sess.push_connect_echo(primed)
|
|
||||||
except Exception:
|
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):
|
elif _looks_like_login_prompt(seed):
|
||||||
# Do not send Enter at Username:/Password: (would empty-submit login).
|
# Do not send Enter at Username:/Password: (would empty-submit login).
|
||||||
try:
|
try:
|
||||||
primed = _capture_raw_channel(conn, duration=0.9)
|
primed = _capture_raw_channel(conn, duration=0.45)
|
||||||
if primed:
|
if primed:
|
||||||
sess.push_connect_echo(primed)
|
sess.push_connect_echo(primed)
|
||||||
except Exception:
|
except Exception:
|
||||||
primed = ""
|
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:
|
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:
|
if primed:
|
||||||
sess.push_connect_echo(primed)
|
sess.push_connect_echo(primed)
|
||||||
except Exception:
|
except Exception:
|
||||||
primed = ""
|
primed = ""
|
||||||
combined = f"{seed}{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:
|
try:
|
||||||
more = _capture_raw_channel(conn, duration=0.35)
|
more = _capture_raw_channel(conn, duration=settle_sec)
|
||||||
if more:
|
if more:
|
||||||
sess.push_connect_echo(more)
|
# Drop a second ``<r1>`` that often races in after password-change login.
|
||||||
combined += more
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if not str(combined).strip():
|
if not str(combined).strip():
|
||||||
try:
|
try:
|
||||||
combined = _drain_channel(conn, rounds=6, wait=0.08)
|
combined = _drain_channel(conn, rounds=4, wait=0.06)
|
||||||
if combined:
|
if combined:
|
||||||
sess.push_connect_echo(combined)
|
sess.push_connect_echo(combined)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -334,22 +379,38 @@ def _finish_connect(
|
||||||
# Replaying prepare_bootstrap_output(combined) caused a full duplicate login.
|
# Replaying prepare_bootstrap_output(combined) caused a full duplicate login.
|
||||||
echoed = sess.connect_echo_text()
|
echoed = sess.connect_echo_text()
|
||||||
bootstrap = ""
|
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():
|
if not echoed.strip():
|
||||||
bootstrap = prepare_bootstrap_output(combined)
|
bootstrap = prepare_bootstrap_output(combined)
|
||||||
try:
|
try:
|
||||||
leftover = _capture_raw_channel(conn, duration=0.12)
|
leftover = _capture_raw_channel(conn, duration=0.1)
|
||||||
except Exception:
|
except Exception:
|
||||||
leftover = ""
|
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)
|
sess.push_connect_echo(leftover)
|
||||||
bootstrap = prepare_bootstrap_output(f"{bootstrap}{leftover}")
|
bootstrap = prepare_bootstrap_output(f"{bootstrap}{leftover}")
|
||||||
else:
|
else:
|
||||||
# Optional unseen tail only (already pushed to connect-echo above when present).
|
|
||||||
try:
|
try:
|
||||||
leftover = _capture_raw_channel(conn, duration=0.12)
|
leftover = _capture_raw_channel(conn, duration=0.08)
|
||||||
except Exception:
|
except Exception:
|
||||||
leftover = ""
|
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)
|
sess.push_connect_echo(leftover)
|
||||||
|
|
||||||
hop_guard = get_cli_hop_guard(conn)
|
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.
|
# Nudge Enter on WS attach only when we still need a shell prompt.
|
||||||
# Never when already at CLI prompt or Username:/Password: (would empty-submit login).
|
# Never when already at CLI prompt or Username:/Password: (would empty-submit login).
|
||||||
final_view = echoed or bootstrap
|
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 = (
|
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()
|
sess.open_session_log()
|
||||||
log_seed = echoed or bootstrap
|
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.append_session_log(log_seed if str(log_seed).endswith("\n") else str(log_seed) + "\n")
|
||||||
sess.start_reader()
|
sess.start_reader()
|
||||||
# Drop late prompt echoes that race into the queue right after reader start.
|
# Drop late prompt echoes that race into the queue right after reader start.
|
||||||
prompt_hint = ""
|
if not prompt_hint and final_view:
|
||||||
if final_view:
|
|
||||||
prompt_hint = str(final_view).replace("\r\n", "\n").replace("\r", "\n").strip().split("\n")[-1].strip()
|
prompt_hint = str(final_view).replace("\r\n", "\n").replace("\r", "\n").strip().split("\n")[-1].strip()
|
||||||
settle_deadline = time.time() + 0.45
|
settle_deadline = time.time() + (0.2 if already_prompted or saw_password_change else 0.35)
|
||||||
while time.time() < settle_deadline:
|
while time.time() < settle_deadline:
|
||||||
try:
|
try:
|
||||||
chunk = sess.out_queue.get_nowait()
|
chunk = sess.out_queue.get_nowait()
|
||||||
|
|
@ -395,12 +458,39 @@ def _finish_connect(
|
||||||
sess.run_post_login_commands()
|
sess.run_post_login_commands()
|
||||||
except Exception:
|
except Exception:
|
||||||
_log.debug("post_login failed session=%s", sess.session_id, exc_info=True)
|
_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.state = "ready"
|
||||||
sess.connect_finished_at = time.time()
|
sess.connect_finished_at = time.time()
|
||||||
sess._ready_event.set()
|
sess._ready_event.set()
|
||||||
elapsed_ms = int((sess.connect_finished_at - sess.connect_started_at) * 1000)
|
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(
|
_audit(
|
||||||
"session_created",
|
"session_created",
|
||||||
session_id=sess.session_id,
|
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 "",
|
hop_vendor=str(creds.get("hop_vendor") or "") if creds.get("hop_enabled") else "",
|
||||||
cli_hop_guard=bool(hop_guard),
|
cli_hop_guard=bool(hop_guard),
|
||||||
cli_hop_prompt=str((hop_guard or {}).get("hop_prompt") or ""),
|
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 "",
|
client=client or "",
|
||||||
connect_ms=elapsed_ms,
|
connect_ms=elapsed_ms,
|
||||||
active=active_session_count(),
|
active=active_session_count(),
|
||||||
|
|
|
||||||
|
|
@ -280,7 +280,9 @@ class BastionConnectImplTests(unittest.TestCase):
|
||||||
password="bastion-pass",
|
password="bastion-pass",
|
||||||
timeout=unittest.mock.ANY,
|
timeout=unittest.mock.ANY,
|
||||||
)
|
)
|
||||||
interact.assert_called_once_with(conn, "target-user", "target-pass", progress_cb=None)
|
interact.assert_called_once_with(
|
||||||
|
conn, "target-user", "target-pass", progress_cb=None, emit_raw=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BastionInteractiveHandlerTests(unittest.TestCase):
|
class BastionInteractiveHandlerTests(unittest.TestCase):
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,30 @@ class HuaweiStelnetAuthTests(unittest.TestCase):
|
||||||
sent = [c.args[1] for c in mock_send.call_args_list]
|
sent = [c.args[1] for c in mock_send.call_args_list]
|
||||||
self.assertEqual(sent, ["ipran", "Y", "N", "secret"])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -108,9 +108,27 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
self.assertTrue(svc._looks_like_cli_prompt("<r1>"))
|
self.assertTrue(svc._looks_like_cli_prompt("<r1>"))
|
||||||
# Stray ':' after Huawei prompt must still count as prompted (no extra Enter).
|
# 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>:"))
|
||||||
|
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.assertEqual(svc.prepare_bootstrap_output("banner\n<r1>:"), "banner\n<r1>")
|
||||||
self.assertTrue(svc._looks_like_password_change_prompt("Change now? [Y/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"))
|
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.
|
# 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(svc._looks_like_password_change_prompt("[Y/N]:"))
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
|
|
@ -173,9 +191,10 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
sess = svc.get_session(out["session_id"])
|
sess = svc.get_session(out["session_id"])
|
||||||
assert sess is not None
|
assert sess is not None
|
||||||
boot = sess.bootstrap_output.decode("utf-8", errors="replace")
|
# Live connect-echo owns the login transcript (bootstrap stays empty to avoid double play).
|
||||||
self.assertIn("****", boot)
|
echo = sess.connect_echo_text()
|
||||||
self.assertIn("R2#", boot)
|
self.assertIn("****", echo)
|
||||||
|
self.assertIn("R2#", echo)
|
||||||
svc.close_session(out["session_id"], reason="test")
|
svc.close_session(out["session_id"], reason="test")
|
||||||
|
|
||||||
@patch.object(reg, "_audit")
|
@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
|
self.assertFalse(out.get("cli_hop")) # bastion hop is not vendor CLI hop guard
|
||||||
sess = svc.get_session(out["session_id"])
|
sess = svc.get_session(out["session_id"])
|
||||||
assert sess is not None
|
assert sess is not None
|
||||||
boot = sess.bootstrap_output.decode("utf-8", errors="replace")
|
echo = sess.connect_echo_text()
|
||||||
self.assertIn("Username:huawei", boot)
|
self.assertIn("Username:huawei", echo)
|
||||||
self.assertIn("<r1>", boot)
|
self.assertIn("<r1>", echo)
|
||||||
self.assertFalse(sess.needs_live_prompt)
|
self.assertFalse(sess.needs_live_prompt)
|
||||||
before = list(fake.written)
|
before = list(fake.written)
|
||||||
sess.write_stdin("\n")
|
sess.write_stdin("\n")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue