Improve WebCRT new-session flow for SecureCRT-like SSH/Telnet use.

Add quick-connect sessions with credential retry, raw interactive drivers, bootstrap banner replay, Huawei telnet prompt cleanup, and session rename/delete in the tree.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 22:52:31 +08:00
parent accd28c9a1
commit f6b399e03e
23 changed files with 3682 additions and 225 deletions

View file

@ -12,10 +12,18 @@ from sqlalchemy.pool import StaticPool
from netx_api.config import settings
from netx_api.db import Base, get_db
from netx_api.main import app
from netx_api.models import ManagedNE, UmeInventoryNE # noqa: F401 — register table on Base
from netx_api.models import CliConnectProfile, ManagedNE, UmeInventoryNE # noqa: F401 — register table on Base
from netx_api.ne_connect import hostname_probe_command, parse_hostname_from_output
from netx_api.ne_crypto import decrypt_secret, encrypt_secret
from netx_api.ne_service import UME_SYNC_SOURCE, create_managed_ne, import_managed_ne
from netx_api.cli_service import list_cli_targets
from netx_api.device_types import WEBCRT_DEVICE_TYPES, WEBCRT_NE_SOURCE
from netx_api.ne_service import (
UME_SYNC_SOURCE,
create_managed_ne,
import_managed_ne,
upsert_webcrt_managed_ne,
upsert_webcrt_session_host,
)
from netx_api.ne_schemas import ManagedNeCreate
@ -319,5 +327,182 @@ class ManagedNeCreateOptionalPasswordTests(unittest.TestCase):
self.assertEqual(row.password_enc, "")
class WebcrtUpsertAndTargetsTests(unittest.TestCase):
def setUp(self):
self._orig = settings.credential_secret_key
settings.credential_secret_key = Fernet.generate_key().decode()
self.engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
ManagedNE.__table__.create(bind=self.engine, checkfirst=True)
UmeInventoryNE.__table__.create(bind=self.engine, checkfirst=True)
CliConnectProfile.__table__.create(bind=self.engine, checkfirst=True)
self.db = sessionmaker(bind=self.engine)()
def tearDown(self):
self.db.close()
settings.credential_secret_key = self._orig
def test_webcrt_device_types_include_linux(self):
self.assertIn("linux", WEBCRT_DEVICE_TYPES)
self.assertIn("generic", WEBCRT_DEVICE_TYPES)
self.assertIn("zte_zxros", WEBCRT_DEVICE_TYPES)
def test_upsert_webcrt_create_update_reuse(self):
created, action = upsert_webcrt_managed_ne(
self.db,
ManagedNeCreate(
name="linux-1",
vendor="Other",
device_type="linux",
ip_address="10.8.8.8",
username="root",
password="secret",
),
)
self.assertEqual(action, "created")
row = self.db.query(ManagedNE).filter(ManagedNE.id == created.id).one()
self.assertEqual(row.source, WEBCRT_NE_SOURCE)
self.assertEqual(row.device_type, "linux")
updated, action2 = upsert_webcrt_managed_ne(
self.db,
ManagedNeCreate(
name="linux-1b",
vendor="Other",
device_type="linux_ssh",
ip_address="10.8.8.8",
username="root",
password="secret2",
),
)
self.assertEqual(action2, "updated")
self.assertEqual(updated.id, created.id)
self.assertEqual(updated.name, "linux-1b")
self.assertEqual(updated.device_type, "linux")
inv = create_managed_ne(
self.db,
ManagedNeCreate(
vendor="ZTE",
device_type="zte_zxros",
ip_address="10.8.8.9",
username="admin",
password="p",
),
)
reused, action3 = upsert_webcrt_managed_ne(
self.db,
ManagedNeCreate(
vendor="ZTE",
device_type="zte_zxros",
ip_address="10.8.8.9",
username="admin",
password="ignored",
),
)
self.assertEqual(action3, "reused")
self.assertEqual(reused.id, inv.id)
# Inventory row must not be rewritten as webcrt.
keep = self.db.query(ManagedNE).filter(ManagedNE.id == inv.id).one()
self.assertNotEqual(keep.source, WEBCRT_NE_SOURCE)
def test_list_cli_targets_webcrt_source(self):
upsert_webcrt_managed_ne(
self.db,
ManagedNeCreate(
name="sess-a",
vendor="Other",
device_type="linux",
ip_address="10.7.7.7",
username="u",
password="p",
),
)
create_managed_ne(
self.db,
ManagedNeCreate(
vendor="ZTE",
device_type="zte_zxros",
ip_address="10.7.7.8",
username="admin",
password="p",
),
)
webcrt = list_cli_targets(self.db, source="webcrt", page=1, page_size=50)
self.assertEqual(webcrt["total"], 1)
self.assertEqual(webcrt["items"][0]["source"], "webcrt")
self.assertEqual(webcrt["items"][0]["ip_address"], "10.7.7.7")
self.assertTrue(webcrt["items"][0]["has_password"])
managed = list_cli_targets(self.db, source="managed", page=1, page_size=50)
ips = {x["ip_address"] for x in managed["items"]}
self.assertIn("10.7.7.8", ips)
self.assertNotIn("10.7.7.7", ips)
def test_upsert_session_host_telnet_no_password(self):
out, action = upsert_webcrt_session_host(
self.db,
name="tn",
ip_address="10.6.6.6",
port=23,
protocol="telnet",
)
self.assertEqual(action, "created")
row = self.db.query(ManagedNE).filter(ManagedNE.id == out.id).one()
self.assertEqual(row.protocol, "telnet")
self.assertEqual(row.password_enc, "")
self.assertEqual(row.device_type, "generic")
self.assertEqual(row.source, WEBCRT_NE_SOURCE)
def test_upsert_session_host_ssh_unsaved_password(self):
out, action = upsert_webcrt_session_host(
self.db,
name="ssh1",
ip_address="10.6.6.7",
port=22,
protocol="ssh",
username="root",
password="ephemeral",
save_password=False,
)
self.assertEqual(action, "created")
row = self.db.query(ManagedNE).filter(ManagedNE.id == out.id).one()
self.assertEqual(row.username, "root")
self.assertEqual(row.password_enc, "")
out2, action2 = upsert_webcrt_session_host(
self.db,
name="ssh1",
ip_address="10.6.6.7",
protocol="ssh",
username="root",
password="secret",
save_password=True,
)
self.assertEqual(action2, "created")
self.assertNotEqual(out2.id, out.id)
self.assertEqual(out2.name, "ssh1 (1)")
row2 = self.db.query(ManagedNE).filter(ManagedNE.id == out2.id).one()
self.assertTrue(str(row2.password_enc or "").strip())
def test_session_host_same_ip_name_suffix(self):
a, _ = upsert_webcrt_session_host(
self.db, ip_address="10.5.5.5", protocol="ssh", username="u", password="p", save_password=True
)
b, _ = upsert_webcrt_session_host(
self.db, ip_address="10.5.5.5", protocol="ssh", username="u", password="p", save_password=True
)
c, _ = upsert_webcrt_session_host(
self.db, ip_address="10.5.5.5", protocol="telnet"
)
self.assertEqual(a.name, "10.5.5.5")
self.assertEqual(b.name, "10.5.5.5 (1)")
self.assertEqual(c.name, "10.5.5.5 (2)")
self.assertEqual(a.ip_address, b.ip_address)
if __name__ == "__main__":
unittest.main()

View file

@ -33,6 +33,13 @@ class CliAuthClassifyTests(unittest.TestCase):
msg = format_cli_failure(AuthenticationException("target_auth_rejected: Permission denied"))
self.assertTrue(msg.startswith("auth_rejected:"))
def test_authentication_exception_empty_message(self):
class AuthenticationException(Exception):
pass
msg = format_cli_failure(AuthenticationException())
self.assertTrue(msg.startswith("auth_rejected:"))
if __name__ == "__main__":
unittest.main()

View file

@ -101,6 +101,119 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertEqual(svc.prepare_bootstrap_output("banner\nR2#\n\nR2#"), "banner\nR2#")
self.assertTrue(svc._is_prompt_only_echo("\r\nR2#\r\n", "R2#"))
self.assertFalse(svc._is_prompt_only_echo("R2#show clock\r\n", "R2#"))
self.assertTrue(svc._looks_like_login_prompt("Username:"))
self.assertTrue(svc._looks_like_login_prompt("login:\nPassword:"))
self.assertFalse(svc._looks_like_login_prompt("<r1>"))
self.assertTrue(svc._looks_like_cli_prompt("<r1>"))
# Stray ':' after Huawei prompt must still count as prompted (no extra Enter).
self.assertTrue(svc._looks_like_cli_prompt("<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.assertFalse(svc._looks_like_password_change_prompt("Change now? [Y/N]:N"))
# WS attach must not send Enter when bootstrap is a login prompt.
self.assertFalse(
(not svc._looks_like_cli_prompt("Username:") and not svc._looks_like_login_prompt("Username:"))
)
self.assertTrue(
(not svc._looks_like_cli_prompt("") and not svc._looks_like_login_prompt(""))
)
def test_capture_raw_channel_keeps_banner(self) -> None:
conn = _FakeConn()
conn.remote_conn.recv_ready.side_effect = [True, True, False, False, False, False]
conn.remote_conn.recv.side_effect = [b"*** IOSv BANNER ***\r\n", b"R2#"]
text = svc._capture_raw_channel(conn, duration=0.2)
self.assertIn("IOSv BANNER", text)
self.assertIn("R2#", text)
self.assertNotIn("MagicMock", text)
@patch.object(svc, "_audit")
@patch.object(svc, "open_netmiko_connection")
@patch("netx_api.cli_resolve.resolve_cli_target")
def test_bootstrap_from_channel_when_session_log_empty(
self,
mock_resolve: MagicMock,
mock_open: MagicMock,
_mock_audit: MagicMock,
) -> None:
"""Interactive generic SSH: banner is on the PTY, not in Netmiko session_log."""
mock_resolve.return_value = (
{"username": "admin", "password": "x", "protocol": "ssh", "ip_address": "192.168.0.128"},
{
"id": "ne-banner",
"name": "R2",
"ip_address": "192.168.0.128",
"protocol": "ssh",
"device_type": "generic",
"source": "webcrt",
},
)
fake = _FakeConn()
# Already at prompt with banner waiting on the channel (no session_log writes).
fake.remote_conn.recv_ready.side_effect = [True, True, False] * 20
fake.remote_conn.recv.side_effect = [
b"**************************************************************************\r\n",
b"R2#",
] + [b""] * 40
def _open(*_a, **_k):
return fake
mock_open.side_effect = _open
out = svc.create_session(
MagicMock(), ne_id="ne-banner", cols=80, rows=24, client="test", async_connect=False
)
sess = svc.get_session(out["session_id"])
assert sess is not None
boot = sess.bootstrap_output.decode("utf-8", errors="replace")
self.assertIn("****", boot)
self.assertIn("R2#", boot)
svc.close_session(out["session_id"], reason="test")
@patch.object(svc, "_audit")
@patch.object(svc, "open_netmiko_connection")
@patch("netx_api.cli_resolve.resolve_cli_target")
def test_create_session_password_override(
self,
mock_resolve: MagicMock,
mock_open: MagicMock,
_mock_audit: MagicMock,
) -> None:
mock_resolve.return_value = (
{
"username": "u",
"password": "",
"hop_enabled": False,
"ip_address": "10.0.0.9",
"protocol": "ssh",
"device_type": "linux",
"port": 22,
},
{
"id": "ne-ephemeral",
"name": "E",
"ip_address": "10.0.0.9",
"protocol": "ssh",
"source": "webcrt",
"device_type": "linux",
},
)
mock_open.side_effect = lambda *a, **k: _FakeConn()
db = MagicMock()
with self.assertRaises(HTTPException) as ctx:
svc.create_session(db, ne_id="ne-ephemeral", async_connect=False)
self.assertEqual(ctx.exception.status_code, 400)
out = svc.create_session(
db,
ne_id="ne-ephemeral",
async_connect=False,
username_override="u",
password_override="once",
)
self.assertEqual(out.get("state"), "ready")
called_creds = mock_open.call_args.args[0] if mock_open.call_args.args else mock_open.call_args[0][0]
self.assertEqual(called_creds.get("password"), "once")
svc.close_session(out["session_id"], reason="test")
@patch.object(svc, "_audit")
@patch.object(svc, "open_netmiko_connection")
@ -190,6 +303,8 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertTrue(called_creds["hop_enabled"])
self.assertEqual(called_creds["hop_vendor"], "bastion")
self.assertIn("session_log", mock_open.call_args.kwargs)
self.assertEqual(mock_open.call_args.kwargs.get("keepalive"), 0)
self.assertEqual(out.get("keepalive_sec"), 0)
fake.remote_conn.resize_pty.assert_called()
self.assertEqual(out["ne_id"], "ne-hop")
self.assertFalse(out.get("cli_hop")) # bastion hop is not vendor CLI hop guard
@ -320,6 +435,17 @@ class WebcrtServiceTests(unittest.TestCase):
{
"username": "u",
"password": "",
"protocol": "ssh",
"hop_enabled": False,
}
)
)
self.assertTrue(
svc._webcrt_creds_ready(
{
"username": "",
"password": "",
"protocol": "telnet",
"hop_enabled": False,
}
)
@ -495,6 +621,17 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertEqual(svc._normalize_encoding("utf8"), "utf-8")
self.assertEqual(svc._encode_text("测", "gbk")[:1], b"\xb2")
def test_linux_telnet_maps_to_generic_telnet(self) -> None:
from netx_api.ne_netmiko import normalize_netmiko_device_type
from netx_api.ne_session_factory import _netmiko_driver_class
dt = normalize_netmiko_device_type("linux", "telnet")
self.assertEqual(dt, "generic_telnet")
self.assertIsNotNone(_netmiko_driver_class(dt))
self.assertEqual(normalize_netmiko_device_type("linux", "ssh"), "linux_ssh")
self.assertEqual(normalize_netmiko_device_type("generic", "ssh"), "generic_termserver_ssh")
self.assertIsNotNone(_netmiko_driver_class("generic_termserver_ssh"))
if __name__ == "__main__":
unittest.main()