mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:50:46 +08:00
Fix Huawei stelnet hop auth hanging on host-key [Y/N] prompts.
Answer Continue/Save-key prompts during CLI-hop secondary auth so WebCRT terminal login no longer times out after connectivity passes.
This commit is contained in:
parent
91a2d1ad6b
commit
6901a7c019
4 changed files with 240 additions and 16 deletions
|
|
@ -374,20 +374,75 @@ def _send_line(conn: ConnectHandler, line: str) -> None:
|
||||||
conn.write_channel(text)
|
conn.write_channel(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_prompt_tail(text: str) -> str:
|
||||||
|
"""Last non-empty line of an auth transcript (prompt detection)."""
|
||||||
|
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
lines = [ln.strip() for ln in s.split("\n") if ln.strip()]
|
||||||
|
return lines[-1] if lines else ""
|
||||||
|
|
||||||
|
|
||||||
def _prompt_needs_auth(text: str) -> tuple[bool, bool]:
|
def _prompt_needs_auth(text: str) -> tuple[bool, bool]:
|
||||||
low = text.lower()
|
"""Detect username/password prompts (Huawei stelnet: ``Please input the username:``)."""
|
||||||
need_user = bool(re.search(r"(username|login|user\s*name)\s*[:>]", low))
|
tail = _auth_prompt_tail(text).lower()
|
||||||
need_pass = bool(re.search(r"password\s*[:>]", low))
|
if not tail:
|
||||||
|
return False, False
|
||||||
|
# Prefer last-line match so earlier echoed prompts in ``acc`` do not stick forever.
|
||||||
|
need_user = bool(
|
||||||
|
re.search(r"(?:please\s+input\s+the\s+)?(?:user\s*name|username|login)\s*[:>]\s*$", tail)
|
||||||
|
)
|
||||||
|
need_pass = bool(re.search(r"(?:enter\s+)?password\s*[:>]\s*$", tail))
|
||||||
return need_user, need_pass
|
return need_user, need_pass
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_needs_host_key_confirm(text: str) -> tuple[bool, bool]:
|
||||||
|
"""Huawei VRP stelnet first-connect host-key prompts.
|
||||||
|
|
||||||
|
Returns ``(continue_access, save_public_key)`` for:
|
||||||
|
- ``The server is not authenticated. Continue to access it? [Y/N]:`` → Y
|
||||||
|
- ``Save the server's public key? [Y/N]:`` → N
|
||||||
|
"""
|
||||||
|
tail = _auth_prompt_tail(text)
|
||||||
|
if not tail:
|
||||||
|
return False, False
|
||||||
|
low = tail.lower()
|
||||||
|
# Do not treat password-change ``Change now? [Y/N]:`` as host-key trust.
|
||||||
|
if re.search(r"(?:change\s*now|please\s*choose|password\s+needs\s+to\s+be\s+changed)", low):
|
||||||
|
return False, False
|
||||||
|
continue_access = bool(
|
||||||
|
re.search(r"(?:not\s+authenticated|continue\s+to\s+access)", low)
|
||||||
|
and re.search(r"\[Y/N\]\s*:\s*$", tail, flags=re.I)
|
||||||
|
)
|
||||||
|
save_key = bool(
|
||||||
|
re.search(r"save\s+the\s+server'?s?\s+public\s+key", low)
|
||||||
|
and re.search(r"\[Y/N\]\s*:\s*$", tail, flags=re.I)
|
||||||
|
)
|
||||||
|
return continue_access, save_key
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_target_cli_prompt(text: str) -> bool:
|
||||||
|
"""True when transcript ends at a device CLI prompt (not ``[Y/N]:`` / login)."""
|
||||||
|
tail = _auth_prompt_tail(text)
|
||||||
|
if not tail:
|
||||||
|
return False
|
||||||
|
if re.search(r"\[Y/N\]", tail, flags=re.I):
|
||||||
|
return False
|
||||||
|
need_user, need_pass = _prompt_needs_auth(tail)
|
||||||
|
if need_user or need_pass:
|
||||||
|
return False
|
||||||
|
# Huawei ``<sysname>`` / ``[sysname]``; Cisco ``R1#`` / ``R1>``.
|
||||||
|
return bool(re.search(r"(?:[>#\]])\s*$", tail)) or bool(re.search(r"^<[^>\r\n]+>\s*$", tail))
|
||||||
|
|
||||||
|
|
||||||
def _interactive_target_auth(conn: ConnectHandler, username: str, password: str) -> None:
|
def _interactive_target_auth(conn: ConnectHandler, username: str, password: str) -> None:
|
||||||
"""Respond to username/password prompts after hop command (target credentials)."""
|
"""Respond to username/password (and Huawei stelnet host-key) prompts after hop command."""
|
||||||
from .ne_cli_errors import find_auth_failure_snippet
|
from .ne_cli_errors import find_auth_failure_snippet
|
||||||
|
|
||||||
deadline = time.time() + int(settings.ne_connect_timeout_sec or 30)
|
# Hop stelnet can show Trying/Connected + two [Y/N] before password; keep budget generous.
|
||||||
|
deadline = time.time() + max(45, int(settings.ne_connect_timeout_sec or 30) + 15)
|
||||||
sent_user = False
|
sent_user = False
|
||||||
sent_pass = False
|
sent_pass = False
|
||||||
|
answered_continue = False
|
||||||
|
answered_save_key = False
|
||||||
acc = ""
|
acc = ""
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
buf = _read_channel(conn, wait=0.3, max_loops=8)
|
buf = _read_channel(conn, wait=0.3, max_loops=8)
|
||||||
|
|
@ -396,7 +451,19 @@ def _interactive_target_auth(conn: ConnectHandler, username: str, password: str)
|
||||||
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}")
|
||||||
need_user, need_pass = _prompt_needs_auth(buf)
|
|
||||||
|
# Match against accumulated tail so prompts split across reads are still seen.
|
||||||
|
continue_access, save_key = _prompt_needs_host_key_confirm(acc)
|
||||||
|
if continue_access and not answered_continue:
|
||||||
|
_send_line(conn, "Y")
|
||||||
|
answered_continue = True
|
||||||
|
continue
|
||||||
|
if save_key and not answered_save_key:
|
||||||
|
_send_line(conn, "N")
|
||||||
|
answered_save_key = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
need_user, need_pass = _prompt_needs_auth(acc)
|
||||||
if need_pass and not sent_pass:
|
if need_pass and not sent_pass:
|
||||||
_send_line(conn, password)
|
_send_line(conn, password)
|
||||||
sent_pass = True
|
sent_pass = True
|
||||||
|
|
@ -405,20 +472,40 @@ def _interactive_target_auth(conn: ConnectHandler, username: str, password: str)
|
||||||
_send_line(conn, username)
|
_send_line(conn, username)
|
||||||
sent_user = True
|
sent_user = True
|
||||||
continue
|
continue
|
||||||
if sent_pass and not need_user and not need_pass:
|
|
||||||
|
if sent_pass and not need_user and not need_pass and not continue_access and not save_key:
|
||||||
|
denied = find_auth_failure_snippet(acc)
|
||||||
|
if denied:
|
||||||
|
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
|
||||||
|
# Prefer a real CLI prompt (Huawei last-login banner may precede it).
|
||||||
|
if _looks_like_target_cli_prompt(acc) or not buf.strip():
|
||||||
|
return
|
||||||
|
# Banner mid-stream after password — keep reading briefly within deadline.
|
||||||
|
time.sleep(0.2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not buf.strip():
|
||||||
|
time.sleep(0.3)
|
||||||
|
continue
|
||||||
|
# Huawei stelnet often reprints the hop ``[sysname]`` after host-key trust and
|
||||||
|
# *before* ``Enter password:``. Do not treat that as target login success.
|
||||||
|
if sent_pass and _looks_like_target_cli_prompt(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}")
|
||||||
return
|
return
|
||||||
if not buf.strip():
|
if (
|
||||||
time.sleep(0.3)
|
sent_user
|
||||||
continue
|
and not sent_pass
|
||||||
if re.search(r"[>#]\s*$", buf):
|
and not answered_continue
|
||||||
|
and not answered_save_key
|
||||||
|
and _looks_like_target_cli_prompt(buf)
|
||||||
|
):
|
||||||
|
# Passwordless target after username only (no stelnet host-key dance).
|
||||||
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}")
|
||||||
if sent_pass or (sent_user and not need_pass):
|
return
|
||||||
return
|
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
denied = find_auth_failure_snippet(acc)
|
denied = find_auth_failure_snippet(acc)
|
||||||
if denied:
|
if denied:
|
||||||
|
|
@ -502,6 +589,16 @@ def _connect_via_cli_hop(
|
||||||
_send_line(conn, "")
|
_send_line(conn, "")
|
||||||
pre = pre + _read_channel(conn, wait=0.35, max_loops=10)
|
pre = pre + _read_channel(conn, wait=0.35, max_loops=10)
|
||||||
hop_prompt = extract_cli_prompt_marker(pre)
|
hop_prompt = extract_cli_prompt_marker(pre)
|
||||||
|
# WebCRT skips hop session_preparation; wait a bit longer for a settled CLI
|
||||||
|
# before stelnet/telnet so the jump command is not typed into a half-ready PTY.
|
||||||
|
if interactive and not hop_prompt:
|
||||||
|
for _ in range(4):
|
||||||
|
more = _read_channel(conn, wait=0.4, max_loops=8)
|
||||||
|
if more:
|
||||||
|
pre += more
|
||||||
|
hop_prompt = extract_cli_prompt_marker(pre)
|
||||||
|
if hop_prompt:
|
||||||
|
break
|
||||||
hop_cmd = render_hop_command(str(creds.get("hop_command_template") or ""), creds)
|
hop_cmd = render_hop_command(str(creds.get("hop_command_template") or ""), creds)
|
||||||
_send_line(conn, hop_cmd)
|
_send_line(conn, hop_cmd)
|
||||||
_interactive_target_auth(conn, str(creds["username"]), str(creds["password"]))
|
_interactive_target_auth(conn, str(creds["username"]), str(creds["password"]))
|
||||||
|
|
|
||||||
|
|
@ -150,14 +150,21 @@ def _looks_like_login_prompt(text: str) -> bool:
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
and are answered in ``_interactive_target_auth``, not by skipping Enter here.
|
||||||
|
"""
|
||||||
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
|
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||||||
lines = [ln.strip() for ln in s.split("\n") if ln.strip()]
|
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)(change\s*now|please\s*choose|password\s+needs\s+to\s+be\s+changed).{0,80}:\s*$", last)) or bool(
|
return bool(
|
||||||
re.search(r"\[Y/N\]\s*:\s*$", last, flags=re.I)
|
re.search(
|
||||||
|
r"(?i)(change\s*now|please\s*choose|password\s+needs\s+to\s+be\s+changed).{0,80}:\s*$",
|
||||||
|
last,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
113
tests/test_cli_hop_target_auth.py
Normal file
113
tests/test_cli_hop_target_auth.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""Unit tests for CLI-hop secondary auth (Huawei stelnet host-key + login)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from netx_api.ne_session_connect import (
|
||||||
|
_interactive_target_auth,
|
||||||
|
_looks_like_target_cli_prompt,
|
||||||
|
_prompt_needs_auth,
|
||||||
|
_prompt_needs_host_key_confirm,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PromptDetectTests(unittest.TestCase):
|
||||||
|
def test_huawei_username_and_password(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
_prompt_needs_auth("Please input the username:"),
|
||||||
|
(True, False),
|
||||||
|
)
|
||||||
|
self.assertEqual(_prompt_needs_auth("Enter password:"), (False, True))
|
||||||
|
self.assertEqual(_prompt_needs_auth("Password:"), (False, True))
|
||||||
|
|
||||||
|
def test_host_key_prompts(self) -> None:
|
||||||
|
cont, save = _prompt_needs_host_key_confirm(
|
||||||
|
"The server is not authenticated. Continue to access it? [Y/N]:"
|
||||||
|
)
|
||||||
|
self.assertTrue(cont)
|
||||||
|
self.assertFalse(save)
|
||||||
|
cont, save = _prompt_needs_host_key_confirm("Save the server's public key? [Y/N]:")
|
||||||
|
self.assertFalse(cont)
|
||||||
|
self.assertTrue(save)
|
||||||
|
|
||||||
|
def test_password_change_not_host_key(self) -> None:
|
||||||
|
cont, save = _prompt_needs_host_key_confirm("Change now? [Y/N]:")
|
||||||
|
self.assertFalse(cont)
|
||||||
|
self.assertFalse(save)
|
||||||
|
|
||||||
|
def test_cli_prompt_rejects_yn(self) -> None:
|
||||||
|
self.assertFalse(_looks_like_target_cli_prompt("Continue? [Y/N]:"))
|
||||||
|
self.assertTrue(_looks_like_target_cli_prompt("<HW-TARGET>"))
|
||||||
|
self.assertTrue(_looks_like_target_cli_prompt("[HW-VM-AR1000V-1]"))
|
||||||
|
|
||||||
|
|
||||||
|
class HuaweiStelnetAuthTests(unittest.TestCase):
|
||||||
|
@patch("netx_api.ne_session_connect._read_channel")
|
||||||
|
@patch("netx_api.ne_session_connect._send_line")
|
||||||
|
def test_answers_host_key_then_login(
|
||||||
|
self,
|
||||||
|
mock_send: MagicMock,
|
||||||
|
mock_read: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Transcript from Huawei AR stelnet first connect to untrusted target."""
|
||||||
|
mock_read.side_effect = [
|
||||||
|
"Please input the username:",
|
||||||
|
(
|
||||||
|
"Trying 1.1.1.2 ...\n"
|
||||||
|
"Press CTRL+K to abort\n"
|
||||||
|
"Connected to 1.1.1.2 ...\n"
|
||||||
|
"The server is not authenticated. Continue to access it? [Y/N]:"
|
||||||
|
),
|
||||||
|
"Save the server's public key? [Y/N]:",
|
||||||
|
(
|
||||||
|
"Jan 1 2017 01:16:39+00:00 HW-VM-AR1000V-1 "
|
||||||
|
"%%01SSH/4/SAVE_PUBLICKEY(l)[3]:When deciding whether to save "
|
||||||
|
"the server's public key 1.1.1.2, the user chose N.\n"
|
||||||
|
"[HW-VM-AR1000V-1]\n"
|
||||||
|
"Enter password:"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
" -----------------------------------------------------------------------------\n"
|
||||||
|
" User last login information:\n"
|
||||||
|
" -----------------------------------------------------------------------------\n"
|
||||||
|
" Access Type: SSH\n"
|
||||||
|
" IP-Address : 172.16.0.6\n"
|
||||||
|
" Time : 2017-01-05 00:11:36+00:00\n"
|
||||||
|
" ----------------\n"
|
||||||
|
"<HW-TARGET>"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
conn = MagicMock()
|
||||||
|
_interactive_target_auth(conn, "ipran", "secret")
|
||||||
|
self.assertEqual(
|
||||||
|
[c.args[1] for c in mock_send.call_args_list],
|
||||||
|
["ipran", "Y", "N", "secret"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("netx_api.ne_session_connect._read_channel")
|
||||||
|
@patch("netx_api.ne_session_connect._send_line")
|
||||||
|
def test_hop_prompt_before_password_not_success(
|
||||||
|
self,
|
||||||
|
mock_send: MagicMock,
|
||||||
|
mock_read: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""``[hop]`` between host-key and password must not end auth early."""
|
||||||
|
mock_read.side_effect = [
|
||||||
|
"Please input the username:",
|
||||||
|
"The server is not authenticated. Continue to access it? [Y/N]:",
|
||||||
|
"Save the server's public key? [Y/N]:",
|
||||||
|
"[HW-VM-AR1000V-1]\n", # hop reprint — must keep waiting
|
||||||
|
"Enter password:",
|
||||||
|
"<TARGET>\n",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
conn = MagicMock()
|
||||||
|
_interactive_target_auth(conn, "ipran", "secret")
|
||||||
|
sent = [c.args[1] for c in mock_send.call_args_list]
|
||||||
|
self.assertEqual(sent, ["ipran", "Y", "N", "secret"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -111,6 +111,13 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
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"))
|
||||||
|
# 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(
|
||||||
|
"The server is not authenticated. Continue to access it? [Y/N]:"
|
||||||
|
)
|
||||||
|
)
|
||||||
# WS attach must not send Enter when bootstrap is a login prompt.
|
# WS attach must not send Enter when bootstrap is a login prompt.
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
(not svc._looks_like_cli_prompt("Username:") and not svc._looks_like_login_prompt("Username:"))
|
(not svc._looks_like_cli_prompt("Username:") and not svc._looks_like_login_prompt("Username:"))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue