Classify CLI auth rejects instead of reporting them as prompt timeouts.

Detect Permission denied in hop login and session logs for config sync and WebCRT.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-31 16:58:47 +08:00
parent 0d1d3200f1
commit 559cc14d2c
5 changed files with 161 additions and 5 deletions

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import io
import logging import logging
import time import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
@ -19,6 +20,7 @@ from .config_sync_commands import command_list, commands_for_vendor
from .config_sync_service import finalize_cycle, sync_cycle_progress from .config_sync_service import finalize_cycle, sync_cycle_progress
from .db import SessionLocal from .db import SessionLocal
from .models import ConfigSyncCycle, ConfigSyncPolicy, ConfigSyncTask, NeConfigHistory, NeConfigSnapshot from .models import ConfigSyncCycle, ConfigSyncPolicy, ConfigSyncTask, NeConfigHistory, NeConfigSnapshot
from .ne_cli_errors import format_cli_failure, session_log_text
from .ne_session_factory import close_netmiko_connection, open_netmiko_connection from .ne_session_factory import close_netmiko_connection, open_netmiko_connection
_log = logging.getLogger("netx.config_sync.runner") _log = logging.getLogger("netx.config_sync.runner")
@ -31,8 +33,7 @@ def _utcnow() -> datetime:
def _format_error(exc: BaseException) -> str: def _format_error(exc: BaseException) -> str:
head = f"{type(exc).__name__}: {exc}" return format_cli_failure(exc, limit=1020)
return head[:1020]
def _pool_for_cycle(cycle_id: str, concurrency: int) -> ThreadPoolExecutor: def _pool_for_cycle(cycle_id: str, concurrency: int) -> ThreadPoolExecutor:
@ -103,11 +104,18 @@ def _claim_task(cycle_id: str, task_id: str) -> bool:
def _collect_commands(creds: dict[str, Any], commands: list[str]) -> list[str]: def _collect_commands(creds: dict[str, Any], commands: list[str]) -> list[str]:
per_cmd = int(settings.ne_collect_read_timeout_sec or 120) per_cmd = int(settings.ne_collect_read_timeout_sec or 120)
session_timeout = per_cmd * max(1, len(commands)) + 60 session_timeout = per_cmd * max(1, len(commands)) + 60
conn = open_netmiko_connection(creds, session_timeout=session_timeout) log_buf = io.BytesIO()
try:
conn = open_netmiko_connection(creds, session_timeout=session_timeout, session_log=log_buf)
except Exception as exc:
raise RuntimeError(format_cli_failure(exc, session_log_text(log_buf))) from exc
try: try:
outputs: list[str] = [] outputs: list[str] = []
for command in commands: for command in commands:
out = conn.send_command(command_string=command, read_timeout=per_cmd) try:
out = conn.send_command(command_string=command, read_timeout=per_cmd)
except Exception as exc:
raise RuntimeError(format_cli_failure(exc, session_log_text(log_buf))) from exc
outputs.append(str(out or "")) outputs.append(str(out or ""))
return outputs return outputs
finally: finally:

90
netx_api/ne_cli_errors.py Normal file
View file

@ -0,0 +1,90 @@
"""Classify CLI/Netmiko failures so auth rejects are not mistaken for read timeouts."""
from __future__ import annotations
import re
from typing import Any
# Prefer specific auth signals over generic Netmiko prompt timeouts.
_AUTH_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
re.compile(p, re.I)
for p in (
r"permission denied\s*\([^)]*password[^)]*\)",
r"permission denied\s*\([^)]*publickey[^)]*\)",
r"permission denied",
r"authentication failed",
r"authentication failure",
r"auth(?:entication)?\s*fail",
r"login\s*(?:invalid|failed|incorrect|rejected)",
r"access denied",
r"bad (?:secret|password|secrets)",
r"incorrect password",
r"%\s*(?:error|login):\s*authentication",
r"username or password is (?:wrong|incorrect|invalid)",
r"wrong password",
r"password rejected",
)
)
_PROMPT_TIMEOUT = re.compile(r"pattern not detected|readtimeout|read timeout", re.I)
def find_auth_failure_snippet(text: str, *, max_len: int = 220) -> str | None:
"""Return a short matching auth-failure line/snippet, or None."""
blob = str(text or "")
if not blob.strip():
return None
for pat in _AUTH_PATTERNS:
m = pat.search(blob)
if not m:
continue
# Prefer the whole line containing the match.
start = blob.rfind("\n", 0, m.start()) + 1
end = blob.find("\n", m.end())
if end < 0:
end = len(blob)
line = blob[start:end].strip()
if not line:
line = m.group(0).strip()
return line[:max_len]
return None
def format_cli_failure(exc: BaseException | str, transcript: str = "", *, limit: int = 1020) -> str:
"""Human/ops-facing failure message; promote auth rejects above Pattern/ReadTimeout."""
if isinstance(exc, BaseException):
exc_text = f"{type(exc).__name__}: {exc}"
else:
exc_text = str(exc or "")
combined = f"{exc_text}\n{transcript or ''}"
auth = find_auth_failure_snippet(combined)
if auth:
# Keep enough of the original class for searchability when it was a timeout wrapper.
if _PROMPT_TIMEOUT.search(exc_text):
msg = f"auth_rejected: {auth} (reported_as_prompt_timeout)"
else:
msg = f"auth_rejected: {auth}"
return msg[:limit]
return exc_text[:limit]
def session_log_text(session_log: Any) -> str:
"""Decode Netmiko session_log file/BytesIO into text."""
if session_log is None:
return ""
try:
if hasattr(session_log, "getvalue"):
raw = session_log.getvalue()
elif hasattr(session_log, "read"):
try:
session_log.seek(0)
except Exception:
pass
raw = session_log.read()
else:
return ""
except Exception:
return ""
if isinstance(raw, bytes):
return raw.decode("utf-8", errors="replace")
return str(raw or "")

View file

@ -489,11 +489,19 @@ def _prompt_needs_auth(text: str) -> tuple[bool, bool]:
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 prompts after hop command (target credentials)."""
from .ne_cli_errors import find_auth_failure_snippet
deadline = time.time() + int(settings.ne_connect_timeout_sec or 30) deadline = time.time() + int(settings.ne_connect_timeout_sec or 30)
sent_user = False sent_user = False
sent_pass = False sent_pass = False
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)
if buf:
acc += buf
denied = find_auth_failure_snippet(acc)
if denied:
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
need_user, need_pass = _prompt_needs_auth(buf) need_user, need_pass = _prompt_needs_auth(buf)
if need_pass and not sent_pass: if need_pass and not sent_pass:
_send_line(conn, password) _send_line(conn, password)
@ -504,14 +512,23 @@ def _interactive_target_auth(conn: ConnectHandler, username: str, password: str)
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:
denied = find_auth_failure_snippet(acc)
if denied:
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
return return
if not buf.strip(): if not buf.strip():
time.sleep(0.3) time.sleep(0.3)
continue continue
if re.search(r"[>#]\s*$", buf): if re.search(r"[>#]\s*$", buf):
denied = find_auth_failure_snippet(acc)
if denied:
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
if sent_pass or (sent_user and not need_pass): 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)
if denied:
raise paramiko.AuthenticationException(f"target_auth_rejected: {denied}")
if not sent_pass: if not sent_pass:
raise TimeoutError("target_auth_timeout") raise TimeoutError("target_auth_timeout")

View file

@ -612,7 +612,10 @@ def create_session(
error=str(exc)[:500], error=str(exc)[:500],
transcript_len=len(partial), transcript_len=len(partial),
) )
detail = f"connect_failed:{exc}" from .ne_cli_errors import format_cli_failure
classified = format_cli_failure(exc, partial)
detail = f"connect_failed:{classified}"
if partial: if partial:
# Keep detail bounded; UI surfaces this on open failure. # Keep detail bounded; UI surfaces this on open failure.
detail = f"{detail}\n--- device transcript ---\n{partial[-4000:]}" detail = f"{detail}\n--- device transcript ---\n{partial[-4000:]}"

View file

@ -0,0 +1,38 @@
"""Unit tests for CLI auth-failure classification."""
from __future__ import annotations
import unittest
from netx_api.ne_cli_errors import find_auth_failure_snippet, format_cli_failure
class CliAuthClassifyTests(unittest.TestCase):
def test_permission_denied_password(self):
text = "banner\nca-oper@114.1.105.3: Permission denied (password).\n"
self.assertIn("Permission denied", find_auth_failure_snippet(text) or "")
def test_prompt_timeout_promoted_to_auth(self):
exc = "ReadTimeout: Pattern not detected: '[>#]' in output."
transcript = "Warning\nca-oper@114.1.105.3: Permission denied (password).\n"
msg = format_cli_failure(exc, transcript)
self.assertTrue(msg.startswith("auth_rejected:"))
self.assertIn("Permission denied", msg)
self.assertIn("prompt_timeout", msg)
def test_plain_timeout_unchanged(self):
exc = RuntimeError("ReadTimeout: Pattern not detected: '[>#]' in output.")
msg = format_cli_failure(exc, "show running-config\n...still dumping...\n")
self.assertIn("ReadTimeout", msg)
self.assertFalse(msg.startswith("auth_rejected:"))
def test_authentication_exception(self):
class AuthenticationException(Exception):
pass
msg = format_cli_failure(AuthenticationException("target_auth_rejected: Permission denied"))
self.assertTrue(msg.startswith("auth_rejected:"))
if __name__ == "__main__":
unittest.main()