diff --git a/netx_api/app_startup.py b/netx_api/app_startup.py index 9e103ef..51cc941 100644 --- a/netx_api/app_startup.py +++ b/netx_api/app_startup.py @@ -11,6 +11,7 @@ from .schema_patches import ( apply_all_legacy_startup_ddl, apply_auth_schema_patches, apply_collection_schema_safety_net, + apply_hop_schema_safety_net, apply_topology_schema_safety_net, run_alembic_upgrade_to_head, ) @@ -56,12 +57,13 @@ def run_api_startup() -> None: try: with engine.begin() as conn: apply_auth_schema_patches(conn) - # Critical topology columns even when full legacy DDL is skipped + # Critical columns even when full legacy DDL is skipped # (e.g. alembic stamped head without applying domain patches). apply_topology_schema_safety_net(conn) apply_collection_schema_safety_net(conn) + apply_hop_schema_safety_net(conn) except Exception: - _log.exception("startup: auth/topology/collection schema safety patches failed") + _log.exception("startup: auth/topology/collection/hop schema safety patches failed") if skip_ddl and alembic_ok: _log.info("startup: schema via Alembic (legacy inline DDL skipped)") else: diff --git a/netx_api/cli_resolve.py b/netx_api/cli_resolve.py index f097982..4227cbc 100644 --- a/netx_api/cli_resolve.py +++ b/netx_api/cli_resolve.py @@ -107,6 +107,7 @@ def profile_to_creds( "hop_command_template": str(profile.hop_command_template or ""), "hop_vrf": str(profile.hop_vrf or ""), "hop_target_auth_mode": str(profile.hop_target_auth_mode or "bastion_managed"), + "hop_enter_system_view": bool(getattr(profile, "hop_enter_system_view", False)), } diff --git a/netx_api/cli_schemas.py b/netx_api/cli_schemas.py index 7a7d90b..badc431 100644 --- a/netx_api/cli_schemas.py +++ b/netx_api/cli_schemas.py @@ -27,6 +27,7 @@ class CliConnectProfileCreate(BaseModel): hop_command_template: str = "" hop_vrf: str = "" hop_target_auth_mode: str = "bastion_managed" + hop_enter_system_view: bool = False @field_validator("vendor_default") @classmethod @@ -60,6 +61,7 @@ class CliConnectProfileUpdate(BaseModel): hop_command_template: str | None = None hop_vrf: str | None = None hop_target_auth_mode: str | None = None + hop_enter_system_view: bool | None = None class CliConnectProfileOut(BaseModel): @@ -81,6 +83,7 @@ class CliConnectProfileOut(BaseModel): hop_command_template: str hop_vrf: str hop_target_auth_mode: str + hop_enter_system_view: bool = False created_at: datetime updated_at: datetime diff --git a/netx_api/cli_service.py b/netx_api/cli_service.py index ad42887..82707c6 100644 --- a/netx_api/cli_service.py +++ b/netx_api/cli_service.py @@ -69,6 +69,7 @@ def _profile_out(row: CliConnectProfile) -> CliConnectProfileOut: hop_command_template=str(row.hop_command_template or ""), hop_vrf=str(row.hop_vrf or ""), hop_target_auth_mode=str(row.hop_target_auth_mode or "bastion_managed"), + hop_enter_system_view=bool(getattr(row, "hop_enter_system_view", False)), created_at=row.created_at, updated_at=row.updated_at, ) @@ -133,6 +134,7 @@ def create_cli_profile(db: Session, body: CliConnectProfileCreate) -> CliConnect hop_command_template=str(body.hop_command_template or "").strip(), hop_vrf=str(body.hop_vrf or "").strip(), hop_target_auth_mode=_normalize_hop_target_auth_mode(body.hop_target_auth_mode), + hop_enter_system_view=bool(body.hop_enter_system_view), ) if body.is_default or db.query(CliConnectProfile).count() == 0: db.query(CliConnectProfile).update({CliConnectProfile.is_default: False}) @@ -180,6 +182,7 @@ def update_cli_profile(db: Session, profile_id: str, body: CliConnectProfileUpda "hop_command_template", "hop_vrf", "hop_target_auth_mode", + "hop_enter_system_view", ) for key in hop_keys: if key in data and data[key] is not None: @@ -190,6 +193,8 @@ def update_cli_profile(db: Session, profile_id: str, body: CliConnectProfileUpda row.hop_protocol = _normalize_protocol(data["hop_protocol"]) if "hop_target_auth_mode" in data and data["hop_target_auth_mode"] is not None: row.hop_target_auth_mode = _normalize_hop_target_auth_mode(data["hop_target_auth_mode"]) + if "hop_enter_system_view" in data and data["hop_enter_system_view"] is not None: + row.hop_enter_system_view = bool(data["hop_enter_system_view"]) if "hop_password" in data and data["hop_password"]: _require_crypto() row.hop_password_enc = encrypt_secret(str(data["hop_password"])) diff --git a/netx_api/models/managed_ne.py b/netx_api/models/managed_ne.py index 03386e7..0fc9fde 100644 --- a/netx_api/models/managed_ne.py +++ b/netx_api/models/managed_ne.py @@ -46,6 +46,8 @@ class ManagedNE(Base): hop_command_template: Mapped[str] = mapped_column(Text, default="") hop_vrf: Mapped[str] = mapped_column(String(128), default="") hop_target_auth_mode: Mapped[str] = mapped_column(String(32), default="bastion_managed") + # Huawei CLI hop: run ``system-view`` before stelnet/telnet (default: stay in user-view). + hop_enter_system_view: Mapped[bool] = mapped_column(default=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive) updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True) @@ -75,6 +77,7 @@ class CliConnectProfile(Base): hop_command_template: Mapped[str] = mapped_column(Text, default="") hop_vrf: Mapped[str] = mapped_column(String(128), default="") hop_target_auth_mode: Mapped[str] = mapped_column(String(32), default="bastion_managed") + hop_enter_system_view: Mapped[bool] = mapped_column(default=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive) updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True) diff --git a/netx_api/ne_schemas.py b/netx_api/ne_schemas.py index 84ead33..3b65fff 100644 --- a/netx_api/ne_schemas.py +++ b/netx_api/ne_schemas.py @@ -31,6 +31,7 @@ class ManagedNeCreate(BaseModel): hop_command_template: str = "" hop_vrf: str = "" hop_target_auth_mode: str = "bastion_managed" + hop_enter_system_view: bool = False @field_validator("vendor") @classmethod @@ -65,6 +66,7 @@ class ManagedNeUpdate(BaseModel): hop_command_template: str | None = None hop_vrf: str | None = None hop_target_auth_mode: str | None = None + hop_enter_system_view: bool | None = None @field_validator("vendor") @classmethod @@ -109,6 +111,7 @@ class ManagedNeOut(BaseModel): hop_command_template: str = "" hop_vrf: str = "" hop_target_auth_mode: str = "bastion_managed" + hop_enter_system_view: bool = False created_at: datetime updated_at: datetime @@ -158,6 +161,7 @@ class HopProxyConfig(BaseModel): hop_command_template: str = "" hop_vrf: str = "" hop_target_auth_mode: str = "bastion_managed" + hop_enter_system_view: bool = False class BatchHopApplyRequest(BaseModel): diff --git a/netx_api/ne_service_common.py b/netx_api/ne_service_common.py index baa4c83..5570ab2 100644 --- a/netx_api/ne_service_common.py +++ b/netx_api/ne_service_common.py @@ -172,6 +172,7 @@ def _apply_hop_create(row: ManagedNE, body: ManagedNeCreate) -> None: row.hop_command_template = str(body.hop_command_template or "").strip() row.hop_vrf = str(body.hop_vrf or "").strip() row.hop_target_auth_mode = _normalize_hop_target_auth_mode(body.hop_target_auth_mode) + row.hop_enter_system_view = bool(body.hop_enter_system_view) def _apply_hop_update(row: ManagedNE, data: dict[str, Any]) -> None: @@ -196,6 +197,8 @@ def _apply_hop_update(row: ManagedNE, data: dict[str, Any]) -> None: row.hop_vrf = str(data["hop_vrf"]).strip() if "hop_target_auth_mode" in data and data["hop_target_auth_mode"] is not None: row.hop_target_auth_mode = _normalize_hop_target_auth_mode(data["hop_target_auth_mode"]) + if "hop_enter_system_view" in data and data["hop_enter_system_view"] is not None: + row.hop_enter_system_view = bool(data["hop_enter_system_view"]) if "hop_host" in data or "hop_username" in data or "hop_vendor" in data: hop_host, hop_username = _normalize_saved_hop_endpoint( hop_vendor=str(row.hop_vendor or ""), @@ -247,6 +250,7 @@ def row_to_out(row: ManagedNE) -> ManagedNeOut: hop_command_template=str(row.hop_command_template or ""), hop_vrf=str(row.hop_vrf or ""), hop_target_auth_mode=str(row.hop_target_auth_mode or "bastion_managed"), + hop_enter_system_view=bool(getattr(row, "hop_enter_system_view", False)), created_at=row.created_at, updated_at=row.updated_at, ) @@ -279,4 +283,5 @@ def get_device_credentials(row: ManagedNE) -> dict[str, Any]: "hop_command_template": str(row.hop_command_template or ""), "hop_vrf": str(row.hop_vrf or ""), "hop_target_auth_mode": str(row.hop_target_auth_mode or "bastion_managed"), + "hop_enter_system_view": bool(getattr(row, "hop_enter_system_view", False)), } diff --git a/netx_api/ne_service_crud.py b/netx_api/ne_service_crud.py index e84d5f4..34a5915 100644 --- a/netx_api/ne_service_crud.py +++ b/netx_api/ne_service_crud.py @@ -173,6 +173,7 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed "hop_command_template", "hop_vrf", "hop_target_auth_mode", + "hop_enter_system_view", ) if any(k in data for k in hop_keys): _apply_hop_update(row, data) @@ -239,6 +240,7 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d row.hop_command_template = template row.hop_vrf = str(hop.hop_vrf or "").strip() row.hop_target_auth_mode = hop_auth_mode + row.hop_enter_system_view = bool(getattr(hop, "hop_enter_system_view", False)) row.updated_at = now db.commit() return {"ok": True, "updated": len(rows)} diff --git a/netx_api/ne_session_connect.py b/netx_api/ne_session_connect.py index 3b1bbcc..5b3b8c2 100644 --- a/netx_api/ne_session_connect.py +++ b/netx_api/ne_session_connect.py @@ -798,6 +798,31 @@ def _resize_pty(conn: ConnectHandler, cols: int | None = None, rows: int | None _log.debug("resize_pty failed cols=%s rows=%s", c, r, exc_info=True) +def _huawei_enter_system_view( + conn: ConnectHandler, + *, + progress_cb: Any = None, + emit_raw: bool = True, +) -> str: + """Enter Huawei system-view; return the new ``[sysname]`` prompt marker if seen.""" + _emit_progress(progress_cb, "\r\n[netx] hop system-view…\r\n") + _send_line(conn, "system-view") + acc = "" + for _ in range(8): + part = _read_channel(conn, wait=0.2, max_loops=10) + if not part: + time.sleep(0.15) + continue + acc += part + if emit_raw: + _emit_progress(progress_cb, part) + marker = extract_cli_prompt_marker(acc) + # System-view prompts are ``[sysname]`` / ``[~sysname]`` (not ````). + if marker.startswith("["): + return marker + return extract_cli_prompt_marker(acc) + + def _connect_via_cli_hop( creds: dict[str, Any], *, @@ -833,13 +858,14 @@ def _connect_via_cli_hop( # WebCRT passes ProgressBytesIO(session_log) that already tees device bytes to progress_cb. # Re-emitting the same reads doubles every line (stelnet, Y/N, MOTD, prompts). teed = isinstance(session_log, _ProgressBytesIO) + emit_raw = not teed conn = _build_netmiko_connection(hop_dev, interactive=interactive) try: # MUST resize before stelnet/telnet — nested session captures hop TTY size at start # and often ignores later WINCH. Wrong width → mid-line edit redraw wraps in WebCRT. _resize_pty(conn, cols, rows) pre = _read_channel(conn, wait=0.35) - if pre and not teed: + if pre and emit_raw: _emit_progress(progress_cb, pre) hop_prompt = extract_cli_prompt_marker(pre) if not hop_prompt: @@ -849,7 +875,7 @@ def _connect_via_cli_hop( except Exception: _send_line(conn, "") more = _read_channel(conn, wait=0.25, max_loops=12) - if more and not teed: + if more and emit_raw: _emit_progress(progress_cb, more) pre = pre + more hop_prompt = extract_cli_prompt_marker(pre) @@ -859,39 +885,48 @@ def _connect_via_cli_hop( for _ in range(6): more = _read_channel(conn, wait=0.3, max_loops=10) if more: - if not teed: + if emit_raw: _emit_progress(progress_cb, more) pre += more hop_prompt = extract_cli_prompt_marker(pre) if hop_prompt: break + + vendor = _hop_vendor(creds) + # Explicit hop option only (default False): never auto-enter system-view on failure. + if vendor == "huawei" and bool(creds.get("hop_enter_system_view")): + sv_prompt = _huawei_enter_system_view(conn, progress_cb=progress_cb, emit_raw=emit_raw) + if sv_prompt: + hop_prompt = sv_prompt + hop_cmd = render_hop_command(str(creds.get("hop_command_template") or ""), creds) _emit_progress(progress_cb, f"\r\n[netx] hop jump: {hop_cmd}\r\n") _send_line(conn, hop_cmd) + _interactive_target_auth( conn, str(creds["username"]), str(creds["password"]), progress_cb=progress_cb, - emit_raw=not teed, + emit_raw=emit_raw, ) _attach_cli_hop_guard( conn, hop_prompt=hop_prompt, - hop_vendor=_hop_vendor(creds), + hop_vendor=vendor, hop_host=hop_host, ) if hop_prompt: _log.info( "cli hop guard armed vendor=%s hop=%s prompt=%r", - _hop_vendor(creds), + vendor, hop_host, hop_prompt, ) else: _log.warning( "cli hop guard armed without hop prompt vendor=%s hop=%s (nested-close only)", - _hop_vendor(creds), + vendor, hop_host, ) return conn diff --git a/netx_api/schema_patches.py b/netx_api/schema_patches.py index 147fffe..30a0226 100644 --- a/netx_api/schema_patches.py +++ b/netx_api/schema_patches.py @@ -199,6 +199,18 @@ def apply_collection_schema_safety_net(conn: Connection) -> None: ) +def apply_hop_schema_safety_net(conn: Connection) -> None: + """Always-on hop columns (Alembic head stamp skips legacy domain patches).""" + _run_sql( + conn, + "ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_enter_system_view BOOLEAN DEFAULT FALSE", + ) + _run_sql( + conn, + "ALTER TABLE cli_connect_profile ADD COLUMN IF NOT EXISTS hop_enter_system_view BOOLEAN DEFAULT FALSE", + ) + + def apply_key_alert_schema_patches( engine: Engine | None = None, *, @@ -348,6 +360,8 @@ def apply_domain_schema_patches(conn: Connection) -> None: "ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_command_template TEXT DEFAULT ''", "ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_vrf VARCHAR(128) DEFAULT ''", "ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_target_auth_mode VARCHAR(32) DEFAULT 'bastion_managed'", + "ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_enter_system_view BOOLEAN DEFAULT FALSE", + "ALTER TABLE cli_connect_profile ADD COLUMN IF NOT EXISTS hop_enter_system_view BOOLEAN DEFAULT FALSE", "ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS source VARCHAR(64) DEFAULT ''", "ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS source_ref VARCHAR(128) DEFAULT ''", "ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS connect_detail TEXT DEFAULT ''", @@ -409,6 +423,7 @@ def apply_domain_schema_patches(conn: Connection) -> None: hop_command_template TEXT DEFAULT '', hop_vrf VARCHAR(128) DEFAULT '', hop_target_auth_mode VARCHAR(32) DEFAULT 'bastion_managed', + hop_enter_system_view BOOLEAN DEFAULT FALSE, created_at TIMESTAMP, updated_at TIMESTAMP ) diff --git a/tests/test_cli_hop_return.py b/tests/test_cli_hop_return.py index 86f24d6..4d1eb1e 100644 --- a/tests/test_cli_hop_return.py +++ b/tests/test_cli_hop_return.py @@ -91,6 +91,84 @@ class CliHopReturnDetectionTests(unittest.TestCase): self.assertEqual(guard["hop_prompt"], "") self.assertEqual(guard["hop_vendor"], "huawei") + @patch("netx_api.ne_session_connect._interactive_target_auth") + @patch("netx_api.ne_session_connect._read_channel") + @patch("netx_api.ne_session_connect.ConnectHandler") + def test_huawei_skips_system_view_by_default( + self, + mock_ch: MagicMock, + mock_read: MagicMock, + mock_auth: MagicMock, + ) -> None: + from netx_api.ne_session_factory import _connect_via_cli_hop + + conn = MagicMock() + conn.remote_conn = MagicMock() + mock_ch.return_value = conn + mock_read.side_effect = ["\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": "stelnet {target_ip}", + "hop_enter_system_view": False, + "username": "target", + "password": "target-pass", + "ip_address": "10.0.0.2", + "port": 22, + } + _connect_via_cli_hop(creds, cols=80, rows=24) + wrote = "".join(str(c.args[0]) for c in conn.write_channel.call_args_list) + self.assertNotIn("system-view", wrote) + self.assertIn("stelnet", wrote) + + @patch("netx_api.ne_session_connect._interactive_target_auth") + @patch("netx_api.ne_session_connect._read_channel") + @patch("netx_api.ne_session_connect.ConnectHandler") + def test_huawei_enters_system_view_when_flag_set( + self, + mock_ch: MagicMock, + mock_read: MagicMock, + mock_auth: MagicMock, + ) -> None: + from netx_api.ne_session_factory import _connect_via_cli_hop + + conn = MagicMock() + conn.remote_conn = MagicMock() + mock_ch.return_value = conn + # First read: user-view prompt; later reads: system-view prompt after system-view. + mock_read.side_effect = ["\n", "[~HOP]\n", "[~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": "stelnet {target_ip}", + "hop_enter_system_view": True, + "username": "target", + "password": "target-pass", + "ip_address": "10.0.0.2", + "port": 22, + } + _connect_via_cli_hop(creds, cols=80, rows=24) + wrote = "".join(str(c.args[0]) for c in conn.write_channel.call_args_list) + self.assertIn("system-view", wrote) + self.assertIn("stelnet", wrote) + # system-view must come before stelnet + self.assertLess(wrote.find("system-view"), wrote.find("stelnet")) + guard = get_cli_hop_guard(conn) + assert guard is not None + self.assertEqual(guard["hop_prompt"], "[~HOP]") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_schema_patches.py b/tests/test_schema_patches.py index dbd5c91..f51b9f5 100644 --- a/tests/test_schema_patches.py +++ b/tests/test_schema_patches.py @@ -13,6 +13,7 @@ import netx_api.models # noqa: F401 from netx_api.schema_patches import ( apply_auth_schema_patches, apply_domain_schema_patches, + apply_hop_schema_safety_net, apply_topology_schema_safety_net, ) @@ -60,6 +61,37 @@ class SchemaPatchesTests(unittest.TestCase): self.assertIn("a_ifname", cols) self.assertIn("z_ifname", cols) + def test_hop_safety_net_adds_enter_system_view(self) -> None: + with self.engine.begin() as conn: + conn.execute(text("DROP TABLE IF EXISTS managed_ne")) + conn.execute( + text( + """ + CREATE TABLE managed_ne ( + id VARCHAR(64) PRIMARY KEY, + hop_enabled BOOLEAN DEFAULT 0 + ) + """ + ) + ) + conn.execute(text("DROP TABLE IF EXISTS cli_connect_profile")) + conn.execute( + text( + """ + CREATE TABLE cli_connect_profile ( + id VARCHAR(64) PRIMARY KEY, + hop_enabled BOOLEAN DEFAULT 0 + ) + """ + ) + ) + apply_hop_schema_safety_net(conn) + apply_hop_schema_safety_net(conn) + ne_cols = {c["name"] for c in inspect(self.engine).get_columns("managed_ne")} + profile_cols = {c["name"] for c in inspect(self.engine).get_columns("cli_connect_profile")} + self.assertIn("hop_enter_system_view", ne_cols) + self.assertIn("hop_enter_system_view", profile_cols) + def test_domain_patches_do_not_raise(self) -> None: with self.engine.begin() as conn: apply_domain_schema_patches(conn) diff --git a/web/src/components/HopProxyFields.tsx b/web/src/components/HopProxyFields.tsx index 950553c..5167267 100644 --- a/web/src/components/HopProxyFields.tsx +++ b/web/src/components/HopProxyFields.tsx @@ -22,6 +22,7 @@ export type HopProxyFieldsState = { hop_command_template: string; hop_vrf: string; hop_target_auth_mode: HopTargetAuthMode; + hop_enter_system_view: boolean; }; export const emptyHopProxyFields = (): HopProxyFieldsState => ({ @@ -34,6 +35,7 @@ export const emptyHopProxyFields = (): HopProxyFieldsState => ({ hop_command_template: defaultHopTemplate("zte", "ssh", ""), hop_vrf: "", hop_target_auth_mode: "bastion_managed", + hop_enter_system_view: false, }); function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) { @@ -224,6 +226,19 @@ export function HopProxyFields({ }} /> + {huawei ? ( + + ) : null}