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

@ -0,0 +1,93 @@
"""Unit tests for vendor CLI hop return-to-proxy detection."""
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
from netx_api.ne_session_factory import (
cli_hop_nested_session_ended,
cli_hop_returned_to_proxy,
extract_cli_prompt_marker,
get_cli_hop_guard,
should_close_cli_hop_session,
)
class CliHopReturnDetectionTests(unittest.TestCase):
def test_extract_huawei_and_cisco_prompts(self) -> None:
self.assertEqual(extract_cli_prompt_marker("banner\n<BJ-CORE>\n"), "<BJ-CORE>")
self.assertEqual(extract_cli_prompt_marker("[BJ-CORE]\n"), "[BJ-CORE]")
self.assertEqual(extract_cli_prompt_marker("R1#"), "R1#")
self.assertEqual(extract_cli_prompt_marker("R1>"), "R1>")
self.assertEqual(extract_cli_prompt_marker(""), "")
def test_extract_strips_ansi(self) -> None:
self.assertEqual(
extract_cli_prompt_marker("\x1b[32m<HOP>\x1b[0m"),
"<HOP>",
)
def test_nested_session_end_messages(self) -> None:
self.assertTrue(cli_hop_nested_session_ended("quit\nConnection closed by foreign host\n"))
self.assertTrue(cli_hop_nested_session_ended("Connection closed\n"))
self.assertTrue(cli_hop_nested_session_ended("% Connection closed by remote host\n"))
self.assertFalse(cli_hop_nested_session_ended("<TARGET>\n"))
def test_returned_to_proxy_requires_exact_last_prompt(self) -> None:
self.assertTrue(cli_hop_returned_to_proxy("x\n<HOP>\n", "<HOP>"))
self.assertFalse(cli_hop_returned_to_proxy("x\n<TARGET>\n", "<HOP>"))
self.assertFalse(cli_hop_returned_to_proxy("mentions <HOP> in text\n<TARGET>\n", "<HOP>"))
def test_should_close_on_nested_end_in_tail(self) -> None:
old = ("old Connection closed by foreign host\n" * 40) + "<TARGET>\n"
self.assertFalse(should_close_cli_hop_session(old, "<HOP>", seen_other_prompt=True))
fresh = old + "Connection closed by foreign host\n<HOP>\n"
self.assertTrue(should_close_cli_hop_session(fresh, "<HOP>", seen_other_prompt=True))
def test_prompt_only_needs_seen_other_prompt(self) -> None:
text = "work\n<HOP>\n"
self.assertFalse(should_close_cli_hop_session(text, "<HOP>", seen_other_prompt=False))
self.assertTrue(should_close_cli_hop_session(text, "<HOP>", seen_other_prompt=True))
@patch("netx_api.ne_session_factory._interactive_target_auth")
@patch("netx_api.ne_session_factory._read_channel")
@patch("netx_api.ne_session_factory.ConnectHandler")
def test_connect_attaches_cli_hop_guard(
self,
mock_ch: MagicMock,
mock_read: MagicMock,
mock_auth: MagicMock,
) -> None:
from netx_api.ne_session_factory import _connect_via_cli_hop
conn = MagicMock()
mock_ch.return_value = conn
mock_read.side_effect = ["<HOP>\n", ""]
mock_auth.return_value = None
creds = {
"hop_host": "10.0.0.1",
"hop_username": "admin",
"hop_password": "hop-pass",
"hop_protocol": "ssh",
"hop_vendor": "huawei",
"hop_port": 22,
"hop_vrf": "",
"hop_command_template": "",
"username": "target",
"password": "target-pass",
"ip_address": "10.0.0.2",
"port": 22,
}
out = _connect_via_cli_hop(creds)
self.assertIs(out, conn)
guard = get_cli_hop_guard(conn)
self.assertIsNotNone(guard)
assert guard is not None
self.assertTrue(guard["enabled"])
self.assertEqual(guard["hop_prompt"], "<HOP>")
self.assertEqual(guard["hop_vendor"], "huawei")
if __name__ == "__main__":
unittest.main()

View file

@ -343,6 +343,81 @@ class WebcrtServiceTests(unittest.TestCase):
svc._reap_sessions()
self.assertIsNone(svc.get_session("idle"))
@patch.object(svc, "_audit")
def test_cli_hop_return_closes_session(self, _mock_audit: MagicMock) -> None:
"""Vendor CLI hop: nested target exit must tear down WebCRT (no hop shell)."""
conn = _FakeConn()
chunks = [
b"<TARGET>\r\n",
b"quit\r\nConnection closed by foreign host\r\n\r\n<HOP>\r\n",
]
idx = {"i": 0}
def recv_ready() -> bool:
return idx["i"] < len(chunks)
def recv(_n: int) -> bytes:
i = idx["i"]
idx["i"] = i + 1
return chunks[i]
conn.remote_conn.recv_ready.side_effect = recv_ready
conn.remote_conn.recv.side_effect = recv
conn.remote_conn.exit_status_ready.return_value = False
sess = svc.WebcrtSession(
session_id="hop1",
ne_id="ne1",
ne_name="lab",
ne_ip="1.2.3.4",
protocol="ssh",
cols=80,
rows=24,
conn=conn, # type: ignore[arg-type]
cli_hop_guard=True,
cli_hop_prompt="<HOP>",
)
with svc._sessions_lock:
svc._sessions["hop1"] = sess
sess.start_reader()
deadline = time.time() + 3.0
got: list[bytes] = []
while time.time() < deadline:
item = sess.take_stdout(0, timeout=0.2)
if item == "empty":
if sess.closed:
break
continue
if item is None:
break
if isinstance(item, bytes):
got.append(item)
self.assertTrue(sess.closed)
self.assertEqual(sess.close_reason, "cli_hop_return")
self.assertIsNone(svc.get_session("hop1"))
blob = b"".join(got).decode("utf-8", errors="replace")
self.assertIn("Connection closed by foreign host", blob)
self.assertIn("目标会话已结束", blob)
def test_cli_hop_note_ignores_same_sysname_until_close_msg(self) -> None:
sess = svc.WebcrtSession(
session_id="hop2",
ne_id="ne1",
ne_name="lab",
ne_ip="1.2.3.4",
protocol="ssh",
cols=80,
rows=24,
cli_hop_guard=True,
cli_hop_prompt="<HUAWEI>",
)
# Same default sysname on target must not trip prompt-only close.
self.assertFalse(sess._note_cli_hop_output(b"<HUAWEI>\r\n"))
self.assertFalse(sess._cli_hop_seen_other_prompt)
self.assertTrue(
sess._note_cli_hop_output(b"Connection closed by foreign host\r\n<HUAWEI>\r\n")
)
if __name__ == "__main__":
unittest.main()