fix(webcrt): close CLI hop sessions when target exit returns to proxy

Nested Huawei/ZTE/Cisco jumps keep the hop channel open after quit/exit; detect nested close or hop prompt return and tear down WebCRT so users cannot operate the proxy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-30 09:49:31 +08:00
parent ec736b6dc8
commit 5fa73c9a4f
4 changed files with 343 additions and 2 deletions

View file

@ -355,6 +355,101 @@ def _send_line(conn: ConnectHandler, line: str) -> None:
conn.write_channel(text)
# Nested stelnet/telnet/ssh on vendor hops ends with messages like these; outer hop stays up.
_CLI_HOP_NESTED_END_RE = re.compile(
r"(?is)"
r"(?:^|\n)\s*(?:"
r"connection\s+closed(?:\s+by\s+(?:foreign|remote)\s+host)?"
r"|closed\s+by\s+foreign\s+host"
r"|connection\s+to\s+\S+\s+closed"
r"|%\s*connection\s+closed(?:\s+by\s+(?:foreign|remote)\s+host)?"
r"|\[connection\s+to\s+[^\]]+closed\]"
r"|remote\s+host\s+closed\s+the\s+connection"
r")[^\n]*\s*(?:\n|$)"
)
# Last-line CLI prompts: <HW> [HW] Router# Router>
_CLI_PROMPT_LINE_RE = re.compile(
r"^(?:"
r"<[^>\r\n]{1,64}>|"
r"\[[^\]\r\n]{1,64}\]|"
r"[A-Za-z0-9][\w.\-:/]{0,62}[#>]"
r")\s*$"
)
def extract_cli_prompt_marker(text: str) -> str:
"""Return the last recognizable CLI prompt line from channel text."""
s = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
for line in reversed(s.split("\n")):
# Strip common ANSI CSI sequences so markers match live reader bytes.
cleaned = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", line).strip()
if cleaned and _CLI_PROMPT_LINE_RE.match(cleaned):
return cleaned
return ""
def cli_hop_nested_session_ended(text: str) -> bool:
"""True when nested jump (stelnet/telnet/ssh) reports connection closed."""
return bool(_CLI_HOP_NESTED_END_RE.search(str(text or "")))
def cli_hop_returned_to_proxy(text: str, hop_prompt: str) -> bool:
"""True when output ends on the hop prompt captured before the jump command."""
marker = str(hop_prompt or "").strip()
if not marker:
return False
last = extract_cli_prompt_marker(text)
return bool(last) and last == marker
def should_close_cli_hop_session(
recent: str,
hop_prompt: str = "",
*,
seen_other_prompt: bool = False,
) -> bool:
"""Policy: end WebCRT when nested target session drops back to the hop CLI.
Nested-close messages are matched only in a trailing window so a mid-session
``display log`` that reprints old "Connection closed" text does not trip.
Prompt-only return requires ``seen_other_prompt`` so identical default sysnames
(e.g. hop and target both ``<HUAWEI>``) do not close immediately after jump.
"""
text = str(recent or "")
if cli_hop_nested_session_ended(text[-800:]):
return True
if not seen_other_prompt:
return False
return cli_hop_returned_to_proxy(text, hop_prompt)
def get_cli_hop_guard(conn: ConnectHandler | None) -> dict[str, Any] | None:
"""Metadata attached by CLI hop connect; None when not a vendor CLI hop session."""
if conn is None:
return None
guard = getattr(conn, "_netx_cli_hop", None)
if not isinstance(guard, dict) or not guard.get("enabled"):
return None
return guard
def _attach_cli_hop_guard(
conn: ConnectHandler,
*,
hop_prompt: str,
hop_vendor: str,
hop_host: str,
) -> None:
conn._netx_cli_hop = { # type: ignore[attr-defined]
"enabled": True,
"hop_prompt": str(hop_prompt or "").strip(),
"hop_vendor": str(hop_vendor or "").strip().lower(),
"hop_host": str(hop_host or "").strip(),
}
def _prompt_needs_auth(text: str) -> tuple[bool, bool]:
low = text.lower()
need_user = bool(re.search(r"(username|login|user\s*name)\s*[:>]", low))
@ -429,10 +524,38 @@ def _connect_via_cli_hop(
)
conn = ConnectHandler(**hop_dev)
try:
_read_channel(conn, wait=0.5)
pre = _read_channel(conn, wait=0.5)
hop_prompt = extract_cli_prompt_marker(pre)
if not hop_prompt:
# Nudge hop CLI once so the prompt is visible for later return-to-proxy detection.
try:
conn.write_channel(getattr(conn, "RETURN", None) or "\n")
except Exception:
_send_line(conn, "")
pre = pre + _read_channel(conn, wait=0.35, max_loops=10)
hop_prompt = extract_cli_prompt_marker(pre)
hop_cmd = render_hop_command(str(creds.get("hop_command_template") or ""), creds)
_send_line(conn, hop_cmd)
_interactive_target_auth(conn, str(creds["username"]), str(creds["password"]))
_attach_cli_hop_guard(
conn,
hop_prompt=hop_prompt,
hop_vendor=_hop_vendor(creds),
hop_host=hop_host,
)
if hop_prompt:
_log.info(
"cli hop guard armed vendor=%s hop=%s prompt=%r",
_hop_vendor(creds),
hop_host,
hop_prompt,
)
else:
_log.warning(
"cli hop guard armed without hop prompt vendor=%s hop=%s (nested-close only)",
_hop_vendor(creds),
hop_host,
)
return conn
except Exception:
try:

View file

@ -21,7 +21,13 @@ from sqlalchemy.orm import Session
from .config import settings
from .ne_crypto import CredentialCryptoError
from .ne_session_factory import close_netmiko_connection, open_netmiko_connection
from .ne_session_factory import (
close_netmiko_connection,
extract_cli_prompt_marker,
get_cli_hop_guard,
open_netmiko_connection,
should_close_cli_hop_session,
)
_log = logging.getLogger("netx.webcrt")
@ -240,9 +246,14 @@ class WebcrtSession:
# Only the newest attach_gen may consume out_queue / mark detach.
attach_gen: int = 0
out_queue: queue.Queue[bytes | None] = field(default_factory=queue.Queue)
# Vendor CLI hop (Huawei/ZTE/Cisco): close when nested target session returns to hop.
cli_hop_guard: bool = False
cli_hop_prompt: str = ""
_reader: threading.Thread | None = field(default=None, repr=False)
_write_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
_stdout_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
_hop_scan_buf: str = field(default="", repr=False)
_cli_hop_seen_other_prompt: bool = field(default=False, repr=False)
def touch(self) -> None:
self.last_activity = time.time()
@ -336,6 +347,7 @@ class WebcrtSession:
self.out_queue.put(None)
return
channel = getattr(conn, "remote_conn", None)
hop_return = False
try:
while not self.closed:
chunk = b""
@ -383,9 +395,42 @@ class WebcrtSession:
if chunk:
self.touch()
self.out_queue.put(chunk)
if self.cli_hop_guard and self._note_cli_hop_output(chunk):
hop_return = True
notice = (
"\r\n*** WebCRT: 目标会话已结束,已断开代理连接 "
"(target session ended; closing hop proxy) ***\r\n"
)
self.out_queue.put(notice.encode("utf-8", errors="replace"))
break
finally:
if hop_return and not self.closed:
# Prefer registry close for audit + remove; fall back to local close.
try:
close_session(self.session_id, reason="cli_hop_return")
except Exception:
self.close("cli_hop_return")
self.out_queue.put(None)
def _note_cli_hop_output(self, chunk: bytes) -> bool:
"""Accumulate stdout and return True when nested CLI hop has returned to proxy."""
try:
text = chunk.decode("utf-8", errors="replace")
except Exception:
text = str(chunk)
self._hop_scan_buf = (self._hop_scan_buf + text)[-12000:]
# Track a prompt that differs from the hop so same-sysname labs still need
# an explicit nested-close message before we tear down.
marker = str(self.cli_hop_prompt or "").strip()
last = extract_cli_prompt_marker(self._hop_scan_buf)
if last and (not marker or last != marker):
self._cli_hop_seen_other_prompt = True
return should_close_cli_hop_session(
self._hop_scan_buf,
self.cli_hop_prompt,
seen_other_prompt=self._cli_hop_seen_other_prompt,
)
def close(self, reason: str = "closed") -> None:
if self.closed:
return
@ -570,6 +615,7 @@ def create_session(
except Exception:
pass
hop_guard = get_cli_hop_guard(conn)
sess = WebcrtSession(
session_id=session_id,
ne_id=target_id,
@ -585,6 +631,8 @@ def create_session(
bootstrap_output=str(bootstrap or "").encode("utf-8", errors="replace"),
# Only nudge a live prompt when transcript has no recognizable prompt yet.
needs_live_prompt=not _looks_like_cli_prompt(bootstrap),
cli_hop_guard=bool(hop_guard),
cli_hop_prompt=str((hop_guard or {}).get("hop_prompt") or ""),
)
# Keep bootstrap for WS attach replay; do not rely solely on out_queue (StrictMode remount).
sess.start_reader()
@ -602,6 +650,8 @@ def create_session(
source=str(device.get("source") or ""),
hop_enabled=bool(creds.get("hop_enabled")),
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 ""),
client=client or "",
active=active_session_count(),
)