mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Add explicit Huawei hop system-view option and startup column safety-net.
Some hops only accept stelnet in system-view; make it a saved yes/no setting (default off) instead of auto-retry, and always ALTER the new column on API boot when Alembic skips legacy DDL. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0a550abcb0
commit
b36174bfa4
20 changed files with 242 additions and 10 deletions
|
|
@ -11,6 +11,7 @@ from .schema_patches import (
|
||||||
apply_all_legacy_startup_ddl,
|
apply_all_legacy_startup_ddl,
|
||||||
apply_auth_schema_patches,
|
apply_auth_schema_patches,
|
||||||
apply_collection_schema_safety_net,
|
apply_collection_schema_safety_net,
|
||||||
|
apply_hop_schema_safety_net,
|
||||||
apply_topology_schema_safety_net,
|
apply_topology_schema_safety_net,
|
||||||
run_alembic_upgrade_to_head,
|
run_alembic_upgrade_to_head,
|
||||||
)
|
)
|
||||||
|
|
@ -56,12 +57,13 @@ def run_api_startup() -> None:
|
||||||
try:
|
try:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
apply_auth_schema_patches(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).
|
# (e.g. alembic stamped head without applying domain patches).
|
||||||
apply_topology_schema_safety_net(conn)
|
apply_topology_schema_safety_net(conn)
|
||||||
apply_collection_schema_safety_net(conn)
|
apply_collection_schema_safety_net(conn)
|
||||||
|
apply_hop_schema_safety_net(conn)
|
||||||
except Exception:
|
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:
|
if skip_ddl and alembic_ok:
|
||||||
_log.info("startup: schema via Alembic (legacy inline DDL skipped)")
|
_log.info("startup: schema via Alembic (legacy inline DDL skipped)")
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ def profile_to_creds(
|
||||||
"hop_command_template": str(profile.hop_command_template or ""),
|
"hop_command_template": str(profile.hop_command_template or ""),
|
||||||
"hop_vrf": str(profile.hop_vrf or ""),
|
"hop_vrf": str(profile.hop_vrf or ""),
|
||||||
"hop_target_auth_mode": str(profile.hop_target_auth_mode or "bastion_managed"),
|
"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)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ class CliConnectProfileCreate(BaseModel):
|
||||||
hop_command_template: str = ""
|
hop_command_template: str = ""
|
||||||
hop_vrf: str = ""
|
hop_vrf: str = ""
|
||||||
hop_target_auth_mode: str = "bastion_managed"
|
hop_target_auth_mode: str = "bastion_managed"
|
||||||
|
hop_enter_system_view: bool = False
|
||||||
|
|
||||||
@field_validator("vendor_default")
|
@field_validator("vendor_default")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -60,6 +61,7 @@ class CliConnectProfileUpdate(BaseModel):
|
||||||
hop_command_template: str | None = None
|
hop_command_template: str | None = None
|
||||||
hop_vrf: str | None = None
|
hop_vrf: str | None = None
|
||||||
hop_target_auth_mode: str | None = None
|
hop_target_auth_mode: str | None = None
|
||||||
|
hop_enter_system_view: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class CliConnectProfileOut(BaseModel):
|
class CliConnectProfileOut(BaseModel):
|
||||||
|
|
@ -81,6 +83,7 @@ class CliConnectProfileOut(BaseModel):
|
||||||
hop_command_template: str
|
hop_command_template: str
|
||||||
hop_vrf: str
|
hop_vrf: str
|
||||||
hop_target_auth_mode: str
|
hop_target_auth_mode: str
|
||||||
|
hop_enter_system_view: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ def _profile_out(row: CliConnectProfile) -> CliConnectProfileOut:
|
||||||
hop_command_template=str(row.hop_command_template or ""),
|
hop_command_template=str(row.hop_command_template or ""),
|
||||||
hop_vrf=str(row.hop_vrf or ""),
|
hop_vrf=str(row.hop_vrf or ""),
|
||||||
hop_target_auth_mode=str(row.hop_target_auth_mode or "bastion_managed"),
|
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,
|
created_at=row.created_at,
|
||||||
updated_at=row.updated_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_command_template=str(body.hop_command_template or "").strip(),
|
||||||
hop_vrf=str(body.hop_vrf 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_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:
|
if body.is_default or db.query(CliConnectProfile).count() == 0:
|
||||||
db.query(CliConnectProfile).update({CliConnectProfile.is_default: False})
|
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_command_template",
|
||||||
"hop_vrf",
|
"hop_vrf",
|
||||||
"hop_target_auth_mode",
|
"hop_target_auth_mode",
|
||||||
|
"hop_enter_system_view",
|
||||||
)
|
)
|
||||||
for key in hop_keys:
|
for key in hop_keys:
|
||||||
if key in data and data[key] is not None:
|
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"])
|
row.hop_protocol = _normalize_protocol(data["hop_protocol"])
|
||||||
if "hop_target_auth_mode" in data and data["hop_target_auth_mode"] is not None:
|
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"])
|
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"]:
|
if "hop_password" in data and data["hop_password"]:
|
||||||
_require_crypto()
|
_require_crypto()
|
||||||
row.hop_password_enc = encrypt_secret(str(data["hop_password"]))
|
row.hop_password_enc = encrypt_secret(str(data["hop_password"]))
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ class ManagedNE(Base):
|
||||||
hop_command_template: Mapped[str] = mapped_column(Text, default="")
|
hop_command_template: Mapped[str] = mapped_column(Text, default="")
|
||||||
hop_vrf: Mapped[str] = mapped_column(String(128), default="")
|
hop_vrf: Mapped[str] = mapped_column(String(128), default="")
|
||||||
hop_target_auth_mode: Mapped[str] = mapped_column(String(32), default="bastion_managed")
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
|
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_command_template: Mapped[str] = mapped_column(Text, default="")
|
||||||
hop_vrf: Mapped[str] = mapped_column(String(128), default="")
|
hop_vrf: Mapped[str] = mapped_column(String(128), default="")
|
||||||
hop_target_auth_mode: Mapped[str] = mapped_column(String(32), default="bastion_managed")
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ class ManagedNeCreate(BaseModel):
|
||||||
hop_command_template: str = ""
|
hop_command_template: str = ""
|
||||||
hop_vrf: str = ""
|
hop_vrf: str = ""
|
||||||
hop_target_auth_mode: str = "bastion_managed"
|
hop_target_auth_mode: str = "bastion_managed"
|
||||||
|
hop_enter_system_view: bool = False
|
||||||
|
|
||||||
@field_validator("vendor")
|
@field_validator("vendor")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -65,6 +66,7 @@ class ManagedNeUpdate(BaseModel):
|
||||||
hop_command_template: str | None = None
|
hop_command_template: str | None = None
|
||||||
hop_vrf: str | None = None
|
hop_vrf: str | None = None
|
||||||
hop_target_auth_mode: str | None = None
|
hop_target_auth_mode: str | None = None
|
||||||
|
hop_enter_system_view: bool | None = None
|
||||||
|
|
||||||
@field_validator("vendor")
|
@field_validator("vendor")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -109,6 +111,7 @@ class ManagedNeOut(BaseModel):
|
||||||
hop_command_template: str = ""
|
hop_command_template: str = ""
|
||||||
hop_vrf: str = ""
|
hop_vrf: str = ""
|
||||||
hop_target_auth_mode: str = "bastion_managed"
|
hop_target_auth_mode: str = "bastion_managed"
|
||||||
|
hop_enter_system_view: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
@ -158,6 +161,7 @@ class HopProxyConfig(BaseModel):
|
||||||
hop_command_template: str = ""
|
hop_command_template: str = ""
|
||||||
hop_vrf: str = ""
|
hop_vrf: str = ""
|
||||||
hop_target_auth_mode: str = "bastion_managed"
|
hop_target_auth_mode: str = "bastion_managed"
|
||||||
|
hop_enter_system_view: bool = False
|
||||||
|
|
||||||
|
|
||||||
class BatchHopApplyRequest(BaseModel):
|
class BatchHopApplyRequest(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -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_command_template = str(body.hop_command_template or "").strip()
|
||||||
row.hop_vrf = str(body.hop_vrf 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_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:
|
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()
|
row.hop_vrf = str(data["hop_vrf"]).strip()
|
||||||
if "hop_target_auth_mode" in data and data["hop_target_auth_mode"] is not None:
|
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"])
|
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:
|
if "hop_host" in data or "hop_username" in data or "hop_vendor" in data:
|
||||||
hop_host, hop_username = _normalize_saved_hop_endpoint(
|
hop_host, hop_username = _normalize_saved_hop_endpoint(
|
||||||
hop_vendor=str(row.hop_vendor or ""),
|
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_command_template=str(row.hop_command_template or ""),
|
||||||
hop_vrf=str(row.hop_vrf or ""),
|
hop_vrf=str(row.hop_vrf or ""),
|
||||||
hop_target_auth_mode=str(row.hop_target_auth_mode or "bastion_managed"),
|
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,
|
created_at=row.created_at,
|
||||||
updated_at=row.updated_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_command_template": str(row.hop_command_template or ""),
|
||||||
"hop_vrf": str(row.hop_vrf or ""),
|
"hop_vrf": str(row.hop_vrf or ""),
|
||||||
"hop_target_auth_mode": str(row.hop_target_auth_mode or "bastion_managed"),
|
"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)),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,7 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed
|
||||||
"hop_command_template",
|
"hop_command_template",
|
||||||
"hop_vrf",
|
"hop_vrf",
|
||||||
"hop_target_auth_mode",
|
"hop_target_auth_mode",
|
||||||
|
"hop_enter_system_view",
|
||||||
)
|
)
|
||||||
if any(k in data for k in hop_keys):
|
if any(k in data for k in hop_keys):
|
||||||
_apply_hop_update(row, data)
|
_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_command_template = template
|
||||||
row.hop_vrf = str(hop.hop_vrf or "").strip()
|
row.hop_vrf = str(hop.hop_vrf or "").strip()
|
||||||
row.hop_target_auth_mode = hop_auth_mode
|
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
|
row.updated_at = now
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True, "updated": len(rows)}
|
return {"ok": True, "updated": len(rows)}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
_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 ``<sysname>``).
|
||||||
|
if marker.startswith("["):
|
||||||
|
return marker
|
||||||
|
return extract_cli_prompt_marker(acc)
|
||||||
|
|
||||||
|
|
||||||
def _connect_via_cli_hop(
|
def _connect_via_cli_hop(
|
||||||
creds: dict[str, Any],
|
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.
|
# 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).
|
# Re-emitting the same reads doubles every line (stelnet, Y/N, MOTD, prompts).
|
||||||
teed = isinstance(session_log, _ProgressBytesIO)
|
teed = isinstance(session_log, _ProgressBytesIO)
|
||||||
|
emit_raw = not teed
|
||||||
conn = _build_netmiko_connection(hop_dev, interactive=interactive)
|
conn = _build_netmiko_connection(hop_dev, interactive=interactive)
|
||||||
try:
|
try:
|
||||||
# MUST resize before stelnet/telnet — nested session captures hop TTY size at start
|
# 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.
|
# and often ignores later WINCH. Wrong width → mid-line edit redraw wraps in WebCRT.
|
||||||
_resize_pty(conn, cols, rows)
|
_resize_pty(conn, cols, rows)
|
||||||
pre = _read_channel(conn, wait=0.35)
|
pre = _read_channel(conn, wait=0.35)
|
||||||
if pre and not teed:
|
if pre and emit_raw:
|
||||||
_emit_progress(progress_cb, pre)
|
_emit_progress(progress_cb, pre)
|
||||||
hop_prompt = extract_cli_prompt_marker(pre)
|
hop_prompt = extract_cli_prompt_marker(pre)
|
||||||
if not hop_prompt:
|
if not hop_prompt:
|
||||||
|
|
@ -849,7 +875,7 @@ def _connect_via_cli_hop(
|
||||||
except Exception:
|
except Exception:
|
||||||
_send_line(conn, "")
|
_send_line(conn, "")
|
||||||
more = _read_channel(conn, wait=0.25, max_loops=12)
|
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)
|
_emit_progress(progress_cb, more)
|
||||||
pre = pre + more
|
pre = pre + more
|
||||||
hop_prompt = extract_cli_prompt_marker(pre)
|
hop_prompt = extract_cli_prompt_marker(pre)
|
||||||
|
|
@ -859,39 +885,48 @@ def _connect_via_cli_hop(
|
||||||
for _ in range(6):
|
for _ in range(6):
|
||||||
more = _read_channel(conn, wait=0.3, max_loops=10)
|
more = _read_channel(conn, wait=0.3, max_loops=10)
|
||||||
if more:
|
if more:
|
||||||
if not teed:
|
if emit_raw:
|
||||||
_emit_progress(progress_cb, more)
|
_emit_progress(progress_cb, more)
|
||||||
pre += more
|
pre += more
|
||||||
hop_prompt = extract_cli_prompt_marker(pre)
|
hop_prompt = extract_cli_prompt_marker(pre)
|
||||||
if hop_prompt:
|
if hop_prompt:
|
||||||
break
|
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)
|
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")
|
_emit_progress(progress_cb, f"\r\n[netx] hop jump: {hop_cmd}\r\n")
|
||||||
_send_line(conn, hop_cmd)
|
_send_line(conn, hop_cmd)
|
||||||
|
|
||||||
_interactive_target_auth(
|
_interactive_target_auth(
|
||||||
conn,
|
conn,
|
||||||
str(creds["username"]),
|
str(creds["username"]),
|
||||||
str(creds["password"]),
|
str(creds["password"]),
|
||||||
progress_cb=progress_cb,
|
progress_cb=progress_cb,
|
||||||
emit_raw=not teed,
|
emit_raw=emit_raw,
|
||||||
)
|
)
|
||||||
_attach_cli_hop_guard(
|
_attach_cli_hop_guard(
|
||||||
conn,
|
conn,
|
||||||
hop_prompt=hop_prompt,
|
hop_prompt=hop_prompt,
|
||||||
hop_vendor=_hop_vendor(creds),
|
hop_vendor=vendor,
|
||||||
hop_host=hop_host,
|
hop_host=hop_host,
|
||||||
)
|
)
|
||||||
if hop_prompt:
|
if hop_prompt:
|
||||||
_log.info(
|
_log.info(
|
||||||
"cli hop guard armed vendor=%s hop=%s prompt=%r",
|
"cli hop guard armed vendor=%s hop=%s prompt=%r",
|
||||||
_hop_vendor(creds),
|
vendor,
|
||||||
hop_host,
|
hop_host,
|
||||||
hop_prompt,
|
hop_prompt,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_log.warning(
|
_log.warning(
|
||||||
"cli hop guard armed without hop prompt vendor=%s hop=%s (nested-close only)",
|
"cli hop guard armed without hop prompt vendor=%s hop=%s (nested-close only)",
|
||||||
_hop_vendor(creds),
|
vendor,
|
||||||
hop_host,
|
hop_host,
|
||||||
)
|
)
|
||||||
return conn
|
return conn
|
||||||
|
|
|
||||||
|
|
@ -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(
|
def apply_key_alert_schema_patches(
|
||||||
engine: Engine | None = None,
|
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_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_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_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 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 source_ref VARCHAR(128) DEFAULT ''",
|
||||||
"ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS connect_detail TEXT 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_command_template TEXT DEFAULT '',
|
||||||
hop_vrf VARCHAR(128) DEFAULT '',
|
hop_vrf VARCHAR(128) DEFAULT '',
|
||||||
hop_target_auth_mode VARCHAR(32) DEFAULT 'bastion_managed',
|
hop_target_auth_mode VARCHAR(32) DEFAULT 'bastion_managed',
|
||||||
|
hop_enter_system_view BOOLEAN DEFAULT FALSE,
|
||||||
created_at TIMESTAMP,
|
created_at TIMESTAMP,
|
||||||
updated_at TIMESTAMP
|
updated_at TIMESTAMP
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,84 @@ class CliHopReturnDetectionTests(unittest.TestCase):
|
||||||
self.assertEqual(guard["hop_prompt"], "<HOP>")
|
self.assertEqual(guard["hop_prompt"], "<HOP>")
|
||||||
self.assertEqual(guard["hop_vendor"], "huawei")
|
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 = ["<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": 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 = ["<HOP>\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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import netx_api.models # noqa: F401
|
||||||
from netx_api.schema_patches import (
|
from netx_api.schema_patches import (
|
||||||
apply_auth_schema_patches,
|
apply_auth_schema_patches,
|
||||||
apply_domain_schema_patches,
|
apply_domain_schema_patches,
|
||||||
|
apply_hop_schema_safety_net,
|
||||||
apply_topology_schema_safety_net,
|
apply_topology_schema_safety_net,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -60,6 +61,37 @@ class SchemaPatchesTests(unittest.TestCase):
|
||||||
self.assertIn("a_ifname", cols)
|
self.assertIn("a_ifname", cols)
|
||||||
self.assertIn("z_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:
|
def test_domain_patches_do_not_raise(self) -> None:
|
||||||
with self.engine.begin() as conn:
|
with self.engine.begin() as conn:
|
||||||
apply_domain_schema_patches(conn)
|
apply_domain_schema_patches(conn)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export type HopProxyFieldsState = {
|
||||||
hop_command_template: string;
|
hop_command_template: string;
|
||||||
hop_vrf: string;
|
hop_vrf: string;
|
||||||
hop_target_auth_mode: HopTargetAuthMode;
|
hop_target_auth_mode: HopTargetAuthMode;
|
||||||
|
hop_enter_system_view: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const emptyHopProxyFields = (): HopProxyFieldsState => ({
|
export const emptyHopProxyFields = (): HopProxyFieldsState => ({
|
||||||
|
|
@ -34,6 +35,7 @@ export const emptyHopProxyFields = (): HopProxyFieldsState => ({
|
||||||
hop_command_template: defaultHopTemplate("zte", "ssh", ""),
|
hop_command_template: defaultHopTemplate("zte", "ssh", ""),
|
||||||
hop_vrf: "",
|
hop_vrf: "",
|
||||||
hop_target_auth_mode: "bastion_managed",
|
hop_target_auth_mode: "bastion_managed",
|
||||||
|
hop_enter_system_view: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
|
function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
|
||||||
|
|
@ -224,6 +226,19 @@ export function HopProxyFields({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{huawei ? (
|
||||||
|
<label className="form-grid__full">
|
||||||
|
<FormLabel>{t("managedNe.hop.enterSystemView")}</FormLabel>
|
||||||
|
<select
|
||||||
|
value={value.hop_enter_system_view ? "yes" : "no"}
|
||||||
|
onChange={(e) => set({ hop_enter_system_view: e.target.value === "yes" })}
|
||||||
|
>
|
||||||
|
<option value="no">{t("managedNe.hop.enterSystemViewNo")}</option>
|
||||||
|
<option value="yes">{t("managedNe.hop.enterSystemViewYes")}</option>
|
||||||
|
</select>
|
||||||
|
<span className="form-field-hint">{t("managedNe.hop.enterSystemViewHint")}</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
<label className="form-grid__full">
|
<label className="form-grid__full">
|
||||||
<FormLabel>{t("managedNe.hop.commandTemplate")}</FormLabel>
|
<FormLabel>{t("managedNe.hop.commandTemplate")}</FormLabel>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ function profileToForm(row: CliConnectProfileItem): ProfileForm {
|
||||||
hop_command_template: row.hop_command_template ?? "",
|
hop_command_template: row.hop_command_template ?? "",
|
||||||
hop_vrf: row.hop_vrf ?? "",
|
hop_vrf: row.hop_vrf ?? "",
|
||||||
hop_target_auth_mode: (row.hop_target_auth_mode || "bastion_managed") as HopProxyFieldsState["hop_target_auth_mode"],
|
hop_target_auth_mode: (row.hop_target_auth_mode || "bastion_managed") as HopProxyFieldsState["hop_target_auth_mode"],
|
||||||
|
hop_enter_system_view: Boolean(row.hop_enter_system_view),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -153,6 +154,7 @@ export function UmeCliConnectPanel({ enabled = true, embedded = false }: { enabl
|
||||||
hop_command_template: form.hop.hop_command_template,
|
hop_command_template: form.hop.hop_command_template,
|
||||||
hop_vrf: form.hop.hop_vrf,
|
hop_vrf: form.hop.hop_vrf,
|
||||||
hop_target_auth_mode: form.hop.hop_target_auth_mode,
|
hop_target_auth_mode: form.hop.hop_target_auth_mode,
|
||||||
|
hop_enter_system_view: form.hop.hop_enter_system_view,
|
||||||
};
|
};
|
||||||
if (form.id) {
|
if (form.id) {
|
||||||
return apiPatch<CliConnectProfileItem>(`/v1/cli/profiles/${form.id}`, body);
|
return apiPatch<CliConnectProfileItem>(`/v1/cli/profiles/${form.id}`, body);
|
||||||
|
|
|
||||||
|
|
@ -931,6 +931,11 @@ const en = {
|
||||||
vrf: "Mgmt VRF (optional)",
|
vrf: "Mgmt VRF (optional)",
|
||||||
vpnInstance: "VPN-Instance (optional)",
|
vpnInstance: "VPN-Instance (optional)",
|
||||||
vrfCisco: "VRF (optional, e.g. MGMT)",
|
vrfCisco: "VRF (optional, e.g. MGMT)",
|
||||||
|
enterSystemView: "Huawei system-view before jump",
|
||||||
|
enterSystemViewNo: "Stay in user-view (default)",
|
||||||
|
enterSystemViewYes: "Enter system-view first",
|
||||||
|
enterSystemViewHint:
|
||||||
|
"Some Huawei hops only accept stelnet/telnet in system-view. Choose explicitly; NetX will not auto-retry.",
|
||||||
commandTemplate: "Jump command template",
|
commandTemplate: "Jump command template",
|
||||||
templateHint:
|
templateHint:
|
||||||
"ZTE CLI: telnet {target_ip}, telnet {target_ip} vrf {vrf}, ssh {target_ip}, ssh {target_ip} vrf {vrf}. Auto-suggested from jump protocol/VRF; same when left blank. Target credentials via secondary auth prompts.",
|
"ZTE CLI: telnet {target_ip}, telnet {target_ip} vrf {vrf}, ssh {target_ip}, ssh {target_ip} vrf {vrf}. Auto-suggested from jump protocol/VRF; same when left blank. Target credentials via secondary auth prompts.",
|
||||||
|
|
|
||||||
|
|
@ -923,6 +923,11 @@ const zh = {
|
||||||
vrf: "管理 VRF(可选)",
|
vrf: "管理 VRF(可选)",
|
||||||
vpnInstance: "VPN-Instance(可选)",
|
vpnInstance: "VPN-Instance(可选)",
|
||||||
vrfCisco: "VRF(可选,如 MGMT)",
|
vrfCisco: "VRF(可选,如 MGMT)",
|
||||||
|
enterSystemView: "跳登前是否进入系统视图",
|
||||||
|
enterSystemViewNo: "不进入(用户视图,默认)",
|
||||||
|
enterSystemViewYes: "进入系统视图(system-view)",
|
||||||
|
enterSystemViewHint:
|
||||||
|
"部分华为跳板仅在系统视图下支持 stelnet/telnet。需自行选择;不会因失败自动重试。",
|
||||||
commandTemplate: "跳登命令模板",
|
commandTemplate: "跳登命令模板",
|
||||||
templateHint:
|
templateHint:
|
||||||
"ZTE 常用:telnet {target_ip}、telnet {target_ip} vrf {vrf}、ssh {target_ip}、ssh {target_ip} vrf {vrf}。按跳板协议与 VRF 自动推荐;留空时后端同样规则。目标账号密码由二次认证提示输入。",
|
"ZTE 常用:telnet {target_ip}、telnet {target_ip} vrf {vrf}、ssh {target_ip}、ssh {target_ip} vrf {vrf}。按跳板协议与 VRF 自动推荐;留空时后端同样规则。目标账号密码由二次认证提示输入。",
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ type FormState = {
|
||||||
hop_command_template: string;
|
hop_command_template: string;
|
||||||
hop_vrf: string;
|
hop_vrf: string;
|
||||||
hop_target_auth_mode: "bastion_managed" | "manual";
|
hop_target_auth_mode: "bastion_managed" | "manual";
|
||||||
|
hop_enter_system_view: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AccountState = {
|
type AccountState = {
|
||||||
|
|
@ -98,6 +99,7 @@ const emptyForm = (): FormState => ({
|
||||||
hop_command_template: defaultHopTemplate("zte", "ssh", ""),
|
hop_command_template: defaultHopTemplate("zte", "ssh", ""),
|
||||||
hop_vrf: "",
|
hop_vrf: "",
|
||||||
hop_target_auth_mode: "bastion_managed",
|
hop_target_auth_mode: "bastion_managed",
|
||||||
|
hop_enter_system_view: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emptyAccount = (): AccountState => ({
|
const emptyAccount = (): AccountState => ({
|
||||||
|
|
@ -250,6 +252,7 @@ export function NePage() {
|
||||||
hop_command_template: form.hop_command_template,
|
hop_command_template: form.hop_command_template,
|
||||||
hop_vrf: form.hop_vrf,
|
hop_vrf: form.hop_vrf,
|
||||||
hop_target_auth_mode: form.hop_target_auth_mode,
|
hop_target_auth_mode: form.hop_target_auth_mode,
|
||||||
|
hop_enter_system_view: form.hop_enter_system_view,
|
||||||
...(form.password ? { password: form.password } : {}),
|
...(form.password ? { password: form.password } : {}),
|
||||||
...(form.hop_password ? { hop_password: form.hop_password } : {}),
|
...(form.hop_password ? { hop_password: form.hop_password } : {}),
|
||||||
};
|
};
|
||||||
|
|
@ -315,6 +318,7 @@ export function NePage() {
|
||||||
hop_command_template: batchHop.hop_command_template.trim(),
|
hop_command_template: batchHop.hop_command_template.trim(),
|
||||||
hop_vrf: batchHop.hop_vrf.trim(),
|
hop_vrf: batchHop.hop_vrf.trim(),
|
||||||
hop_target_auth_mode: batchHop.hop_target_auth_mode,
|
hop_target_auth_mode: batchHop.hop_target_auth_mode,
|
||||||
|
hop_enter_system_view: batchHop.hop_enter_system_view,
|
||||||
}),
|
}),
|
||||||
onSuccess: async (res) => {
|
onSuccess: async (res) => {
|
||||||
setBatchHopOpen(false);
|
setBatchHopOpen(false);
|
||||||
|
|
@ -415,6 +419,7 @@ export function NePage() {
|
||||||
hop_command_template: bulkHop.hop_command_template.trim(),
|
hop_command_template: bulkHop.hop_command_template.trim(),
|
||||||
hop_vrf: bulkHop.hop_vrf.trim(),
|
hop_vrf: bulkHop.hop_vrf.trim(),
|
||||||
hop_target_auth_mode: bulkHop.hop_target_auth_mode,
|
hop_target_auth_mode: bulkHop.hop_target_auth_mode,
|
||||||
|
hop_enter_system_view: bulkHop.hop_enter_system_view,
|
||||||
});
|
});
|
||||||
return { type: "proxy" as const, n: res.updated };
|
return { type: "proxy" as const, n: res.updated };
|
||||||
},
|
},
|
||||||
|
|
@ -485,6 +490,7 @@ export function NePage() {
|
||||||
hop_vrf: row.hop_vrf,
|
hop_vrf: row.hop_vrf,
|
||||||
hop_target_auth_mode:
|
hop_target_auth_mode:
|
||||||
row.hop_target_auth_mode === "manual" ? "manual" : "bastion_managed",
|
row.hop_target_auth_mode === "manual" ? "manual" : "bastion_managed",
|
||||||
|
hop_enter_system_view: Boolean(row.hop_enter_system_view),
|
||||||
});
|
});
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
@ -1129,6 +1135,7 @@ export function NePage() {
|
||||||
hop_command_template: form.hop_command_template,
|
hop_command_template: form.hop_command_template,
|
||||||
hop_vrf: form.hop_vrf,
|
hop_vrf: form.hop_vrf,
|
||||||
hop_target_auth_mode: form.hop_target_auth_mode,
|
hop_target_auth_mode: form.hop_target_auth_mode,
|
||||||
|
hop_enter_system_view: form.hop_enter_system_view,
|
||||||
}}
|
}}
|
||||||
onChange={(patch) => setForm((prev) => ({ ...prev, ...patch }))}
|
onChange={(patch) => setForm((prev) => ({ ...prev, ...patch }))}
|
||||||
hopPasswordRequired={!editing}
|
hopPasswordRequired={!editing}
|
||||||
|
|
|
||||||
|
|
@ -555,6 +555,7 @@ export const batchApplyHopManagedNe = (
|
||||||
hop_vrf: string;
|
hop_vrf: string;
|
||||||
hop_vendor?: string;
|
hop_vendor?: string;
|
||||||
hop_target_auth_mode?: string;
|
hop_target_auth_mode?: string;
|
||||||
|
hop_enter_system_view?: boolean;
|
||||||
},
|
},
|
||||||
) => apiPost<{ ok: boolean; updated: number }>("/v1/managed-ne/batch-hop", { ids, hop });
|
) => apiPost<{ ok: boolean; updated: number }>("/v1/managed-ne/batch-hop", { ids, hop });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,7 @@ export type ManagedNeItem = {
|
||||||
hop_command_template: string;
|
hop_command_template: string;
|
||||||
hop_vrf: string;
|
hop_vrf: string;
|
||||||
hop_target_auth_mode: string;
|
hop_target_auth_mode: string;
|
||||||
|
hop_enter_system_view?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
@ -255,6 +256,7 @@ export type CliConnectProfileItem = {
|
||||||
hop_command_template: string;
|
hop_command_template: string;
|
||||||
hop_vrf: string;
|
hop_vrf: string;
|
||||||
hop_target_auth_mode: string;
|
hop_target_auth_mode: string;
|
||||||
|
hop_enter_system_view?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -128,9 +128,16 @@ export function patchHopVendorChange(
|
||||||
hop_command_template: string;
|
hop_command_template: string;
|
||||||
hop_port?: number;
|
hop_port?: number;
|
||||||
hop_target_auth_mode?: HopTargetAuthMode;
|
hop_target_auth_mode?: HopTargetAuthMode;
|
||||||
|
hop_enter_system_view?: boolean;
|
||||||
} {
|
} {
|
||||||
if (vendor === "linux") {
|
if (vendor === "linux") {
|
||||||
return { hop_vendor: "linux", hop_protocol: "ssh", hop_vrf: "", hop_command_template: "" };
|
return {
|
||||||
|
hop_vendor: "linux",
|
||||||
|
hop_protocol: "ssh",
|
||||||
|
hop_vrf: "",
|
||||||
|
hop_command_template: "",
|
||||||
|
hop_enter_system_view: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (vendor === "bastion") {
|
if (vendor === "bastion") {
|
||||||
return {
|
return {
|
||||||
|
|
@ -140,6 +147,7 @@ export function patchHopVendorChange(
|
||||||
hop_vrf: "",
|
hop_vrf: "",
|
||||||
hop_command_template: bastionHopTemplate(),
|
hop_command_template: bastionHopTemplate(),
|
||||||
hop_target_auth_mode: "bastion_managed" as HopTargetAuthMode,
|
hop_target_auth_mode: "bastion_managed" as HopTargetAuthMode,
|
||||||
|
hop_enter_system_view: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const protocol = prev.hop_protocol || "ssh";
|
const protocol = prev.hop_protocol || "ssh";
|
||||||
|
|
@ -149,6 +157,8 @@ export function patchHopVendorChange(
|
||||||
hop_protocol: protocol,
|
hop_protocol: protocol,
|
||||||
hop_vrf: vrf,
|
hop_vrf: vrf,
|
||||||
hop_command_template: defaultHopTemplate(vendor, protocol, vrf),
|
hop_command_template: defaultHopTemplate(vendor, protocol, vrf),
|
||||||
|
// Explicit Huawei option only; reset when leaving/entering other CLI hop vendors.
|
||||||
|
hop_enter_system_view: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue