mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
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:
parent
ec736b6dc8
commit
5fa73c9a4f
4 changed files with 343 additions and 2 deletions
93
tests/test_cli_hop_return.py
Normal file
93
tests/test_cli_hop_return.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue