mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
feat(ne): connect detail, Huawei/Cisco hop, Cisco hostname probe
Persist full connect test logs (connect_detail) with NE UI detail modal. Add Huawei/Cisco jump CLI templates and generic CLI hop session path. Probe Cisco hostname via show configuration | include hostname (60s timeout). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
778442dc57
commit
dc17f9d15a
15 changed files with 457 additions and 63 deletions
|
|
@ -749,6 +749,7 @@ def on_startup() -> None:
|
||||||
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_password_enc TEXT DEFAULT ''")
|
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_password_enc TEXT DEFAULT ''")
|
||||||
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_command_template TEXT DEFAULT ''")
|
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_command_template TEXT DEFAULT ''")
|
||||||
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_vrf VARCHAR(128) DEFAULT ''")
|
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_vrf VARCHAR(128) DEFAULT ''")
|
||||||
|
conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS connect_detail TEXT DEFAULT ''")
|
||||||
conn.exec_driver_sql(
|
conn.exec_driver_sql(
|
||||||
"ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP"
|
"ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,7 @@ class ManagedNE(Base):
|
||||||
enable_secret_enc: Mapped[str] = mapped_column(Text, default="")
|
enable_secret_enc: Mapped[str] = mapped_column(Text, default="")
|
||||||
connect_status: Mapped[str] = mapped_column(String(32), default="unknown", index=True)
|
connect_status: Mapped[str] = mapped_column(String(32), default="unknown", index=True)
|
||||||
connect_message: Mapped[str] = mapped_column(String(512), default="")
|
connect_message: Mapped[str] = mapped_column(String(512), default="")
|
||||||
|
connect_detail: Mapped[str] = mapped_column(Text, default="")
|
||||||
connect_tested_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
connect_tested_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
site: Mapped[str] = mapped_column(String(256), default="")
|
site: Mapped[str] = mapped_column(String(256), default="")
|
||||||
tags: Mapped[str] = mapped_column(String(512), default="")
|
tags: Mapped[str] = mapped_column(String(512), default="")
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import traceback
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -15,6 +16,7 @@ from .ne_session_factory import close_netmiko_connection, open_netmiko_connectio
|
||||||
|
|
||||||
_log = logging.getLogger("netx.ne.connect")
|
_log = logging.getLogger("netx.ne.connect")
|
||||||
_executor: ThreadPoolExecutor | None = None
|
_executor: ThreadPoolExecutor | None = None
|
||||||
|
_DETAIL_MAX = 8000
|
||||||
|
|
||||||
|
|
||||||
def _executor_pool() -> ThreadPoolExecutor:
|
def _executor_pool() -> ThreadPoolExecutor:
|
||||||
|
|
@ -25,10 +27,39 @@ def _executor_pool() -> ThreadPoolExecutor:
|
||||||
return _executor
|
return _executor
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_detail(text: str) -> str:
|
||||||
|
return str(text or "")[:_DETAIL_MAX]
|
||||||
|
|
||||||
|
|
||||||
|
def _connect_context_lines(creds: dict[str, Any]) -> list[str]:
|
||||||
|
lines = [
|
||||||
|
f"target={creds.get('ip_address')}:{creds.get('port')}/{creds.get('protocol')}",
|
||||||
|
f"device_type={creds.get('device_type')} vendor={creds.get('vendor')}",
|
||||||
|
f"username={creds.get('username')}",
|
||||||
|
]
|
||||||
|
if creds.get("hop_enabled"):
|
||||||
|
lines.append(
|
||||||
|
"hop="
|
||||||
|
f"enabled vendor={creds.get('hop_vendor')} "
|
||||||
|
f"host={creds.get('hop_host')}:{creds.get('hop_port')}/{creds.get('hop_protocol')} "
|
||||||
|
f"user={creds.get('hop_username')}"
|
||||||
|
)
|
||||||
|
tpl = str(creds.get("hop_command_template") or "").strip()
|
||||||
|
if tpl:
|
||||||
|
lines.append(f"hop_command_template={tpl}")
|
||||||
|
vrf = str(creds.get("hop_vrf") or "").strip()
|
||||||
|
if vrf:
|
||||||
|
lines.append(f"hop_vrf={vrf}")
|
||||||
|
else:
|
||||||
|
lines.append("hop=disabled (direct)")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def hostname_probe_command(device_type: str, vendor: str) -> str | None:
|
def hostname_probe_command(device_type: str, vendor: str) -> str | None:
|
||||||
"""
|
"""
|
||||||
Per-vendor CLI to read system name (ported from legacy connect.extract_dev_command).
|
Per-vendor CLI to read system name (ported from legacy connect.extract_dev_command).
|
||||||
ZTE: rely on login prompt / empty command path.
|
ZTE: rely on login prompt when no dedicated command.
|
||||||
|
Cisco: show configuration filter; Huawei: current-configuration sysname.
|
||||||
"""
|
"""
|
||||||
dt = str(device_type or "").lower()
|
dt = str(device_type or "").lower()
|
||||||
v = str(vendor or "").lower()
|
v = str(vendor or "").lower()
|
||||||
|
|
@ -37,7 +68,7 @@ def hostname_probe_command(device_type: str, vendor: str) -> str | None:
|
||||||
if "juniper" in dt or v == "juniper":
|
if "juniper" in dt or v == "juniper":
|
||||||
return "show system host-name"
|
return "show system host-name"
|
||||||
if "cisco" in dt or v == "cisco":
|
if "cisco" in dt or v == "cisco":
|
||||||
return "show hostname"
|
return "show configuration | include hostname"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -68,12 +99,15 @@ def parse_hostname_from_output(
|
||||||
return m.group(1).strip().rstrip(";")
|
return m.group(1).strip().rstrip(";")
|
||||||
|
|
||||||
if "cisco" in dt or v == "cisco":
|
if "cisco" in dt or v == "cisco":
|
||||||
|
m = re.search(r"hostname\s+(\S+)", text, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
return m.group(1).strip()
|
||||||
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
||||||
for ln in reversed(lines):
|
for ln in reversed(lines):
|
||||||
if ln.startswith("%") or "invalid" in ln.lower():
|
if ln.startswith("%") or "invalid" in ln.lower():
|
||||||
continue
|
continue
|
||||||
token = ln.split()[0].strip("<>[]")
|
token = ln.split()[0].strip("<>[]")
|
||||||
if token:
|
if token and token.lower() != "hostname":
|
||||||
return token
|
return token
|
||||||
|
|
||||||
if "zte" in dt or v == "zte":
|
if "zte" in dt or v == "zte":
|
||||||
|
|
@ -102,7 +136,8 @@ def _clean_prompt_hostname(prompt: str) -> str | None:
|
||||||
|
|
||||||
def _classify_connect_error(creds: dict[str, Any], exc: BaseException) -> str:
|
def _classify_connect_error(creds: dict[str, Any], exc: BaseException) -> str:
|
||||||
raw = str(exc).lower()
|
raw = str(exc).lower()
|
||||||
detail = str(exc).split("\n")[0][:480]
|
full = str(exc).strip()
|
||||||
|
detail = full.split("\n")[0][:480] if full else type(exc).__name__
|
||||||
if creds.get("hop_enabled"):
|
if creds.get("hop_enabled"):
|
||||||
hop_v = str(creds.get("hop_vendor") or "zte").lower()
|
hop_v = str(creds.get("hop_vendor") or "zte").lower()
|
||||||
if "hop_credentials_incomplete" in raw or "hop_command_template_invalid" in raw:
|
if "hop_credentials_incomplete" in raw or "hop_command_template_invalid" in raw:
|
||||||
|
|
@ -118,11 +153,50 @@ def _classify_connect_error(creds: dict[str, Any], exc: BaseException) -> str:
|
||||||
if hop_v == "linux":
|
if hop_v == "linux":
|
||||||
return "hop_connect_failed: " + detail
|
return "hop_connect_failed: " + detail
|
||||||
return "hop_command_failed: " + detail
|
return "hop_command_failed: " + detail
|
||||||
|
if "readtimeout" in raw.replace(" ", "") or "pattern not detected" in raw:
|
||||||
|
return "probe_command_timeout: " + detail
|
||||||
return detail
|
return detail
|
||||||
|
|
||||||
|
|
||||||
def _probe_device(creds: dict[str, Any]) -> tuple[str, str, str | None]:
|
def _format_failure_detail(creds: dict[str, Any], exc: BaseException) -> str:
|
||||||
"""Login via Netmiko, probe hostname, return (status, message, discovered_name)."""
|
lines = _connect_context_lines(creds)
|
||||||
|
lines.append(f"result=fail")
|
||||||
|
lines.append(f"error={type(exc).__name__}: {exc}")
|
||||||
|
tb = traceback.format_exc().strip()
|
||||||
|
if tb:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(tb)
|
||||||
|
return _truncate_detail("\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
_PROBE_READ_TIMEOUT = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _format_success_detail(
|
||||||
|
creds: dict[str, Any],
|
||||||
|
*,
|
||||||
|
prompt: str,
|
||||||
|
command: str | None,
|
||||||
|
output: str,
|
||||||
|
hostname: str | None,
|
||||||
|
summary: str,
|
||||||
|
) -> str:
|
||||||
|
lines = _connect_context_lines(creds)
|
||||||
|
lines.append(f"result=pass summary={summary}")
|
||||||
|
if prompt:
|
||||||
|
lines.append(f"prompt={prompt}")
|
||||||
|
if command:
|
||||||
|
lines.append(f"probe_command={command}")
|
||||||
|
if output:
|
||||||
|
lines.append("probe_output:")
|
||||||
|
lines.append(output[:3000])
|
||||||
|
if hostname:
|
||||||
|
lines.append(f"parsed_hostname={hostname}")
|
||||||
|
return _truncate_detail("\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_device(creds: dict[str, Any]) -> tuple[str, str, str | None, str]:
|
||||||
|
"""Login via Netmiko, probe hostname; return (status, message, discovered_name, detail)."""
|
||||||
vendor = str(creds.get("vendor") or "")
|
vendor = str(creds.get("vendor") or "")
|
||||||
session_timeout = 180 if creds.get("hop_enabled") else None
|
session_timeout = 180 if creds.get("hop_enabled") else None
|
||||||
conn = None
|
conn = None
|
||||||
|
|
@ -132,23 +206,64 @@ def _probe_device(creds: dict[str, Any]) -> tuple[str, str, str | None]:
|
||||||
command = hostname_probe_command(creds["device_type"], vendor)
|
command = hostname_probe_command(creds["device_type"], vendor)
|
||||||
output = ""
|
output = ""
|
||||||
if command:
|
if command:
|
||||||
output = conn.send_command(command_string=command, read_timeout=30)
|
output = conn.send_command(command_string=command, read_timeout=_PROBE_READ_TIMEOUT)
|
||||||
hostname = parse_hostname_from_output(creds["device_type"], vendor, output, prompt)
|
hostname = parse_hostname_from_output(creds["device_type"], vendor, output, prompt)
|
||||||
if hostname:
|
if hostname:
|
||||||
return "pass", f"connected: {hostname}", hostname
|
msg = f"connected: {hostname}"
|
||||||
|
return (
|
||||||
|
"pass",
|
||||||
|
msg,
|
||||||
|
hostname,
|
||||||
|
_format_success_detail(
|
||||||
|
creds, prompt=prompt, command=command, output=output, hostname=hostname, summary=msg
|
||||||
|
),
|
||||||
|
)
|
||||||
if command:
|
if command:
|
||||||
return "pass", "connected (hostname not parsed)", None
|
msg = "connected (hostname not parsed)"
|
||||||
|
return (
|
||||||
|
"pass",
|
||||||
|
msg,
|
||||||
|
None,
|
||||||
|
_format_success_detail(creds, prompt=prompt, command=command, output=output, hostname=None, summary=msg),
|
||||||
|
)
|
||||||
fallback = _clean_prompt_hostname(prompt)
|
fallback = _clean_prompt_hostname(prompt)
|
||||||
if fallback:
|
if fallback:
|
||||||
return "pass", f"connected: {fallback}", fallback
|
msg = f"connected: {fallback}"
|
||||||
return "pass", "connected", None
|
return (
|
||||||
|
"pass",
|
||||||
|
msg,
|
||||||
|
fallback,
|
||||||
|
_format_success_detail(
|
||||||
|
creds, prompt=prompt, command=command, output=output, hostname=fallback, summary=msg
|
||||||
|
),
|
||||||
|
)
|
||||||
|
msg = "connected"
|
||||||
|
return (
|
||||||
|
"pass",
|
||||||
|
msg,
|
||||||
|
None,
|
||||||
|
_format_success_detail(creds, prompt=prompt, command=command, output=output, hostname=None, summary=msg),
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return "fail", _classify_connect_error(creds, exc), None
|
_log.exception(
|
||||||
|
"connect probe failed target=%s hop=%s",
|
||||||
|
creds.get("ip_address"),
|
||||||
|
creds.get("hop_enabled"),
|
||||||
|
)
|
||||||
|
msg = _classify_connect_error(creds, exc)
|
||||||
|
return "fail", msg, None, _format_failure_detail(creds, exc)
|
||||||
finally:
|
finally:
|
||||||
close_netmiko_connection(conn)
|
close_netmiko_connection(conn)
|
||||||
|
|
||||||
|
|
||||||
def _update_row(ne_id: str, status: str, message: str, discovered_name: str | None = None) -> None:
|
def _update_row(
|
||||||
|
ne_id: str,
|
||||||
|
status: str,
|
||||||
|
message: str,
|
||||||
|
discovered_name: str | None = None,
|
||||||
|
*,
|
||||||
|
detail: str = "",
|
||||||
|
) -> None:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
row = db.get(ManagedNE, ne_id)
|
row = db.get(ManagedNE, ne_id)
|
||||||
|
|
@ -156,6 +271,7 @@ def _update_row(ne_id: str, status: str, message: str, discovered_name: str | No
|
||||||
return
|
return
|
||||||
row.connect_status = status
|
row.connect_status = status
|
||||||
row.connect_message = str(message or "")[:500]
|
row.connect_message = str(message or "")[:500]
|
||||||
|
row.connect_detail = _truncate_detail(detail)
|
||||||
row.connect_tested_at = datetime.utcnow()
|
row.connect_tested_at = datetime.utcnow()
|
||||||
if discovered_name:
|
if discovered_name:
|
||||||
row.name = discovered_name[:256]
|
row.name = discovered_name[:256]
|
||||||
|
|
@ -173,18 +289,38 @@ def _run_single(ne_id: str) -> None:
|
||||||
return
|
return
|
||||||
row.connect_status = "testing"
|
row.connect_status = "testing"
|
||||||
row.connect_message = ""
|
row.connect_message = ""
|
||||||
|
row.connect_detail = ""
|
||||||
row.updated_at = datetime.utcnow()
|
row.updated_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
try:
|
try:
|
||||||
creds = get_device_credentials(row)
|
creds = get_device_credentials(row)
|
||||||
except CredentialCryptoError as exc:
|
except CredentialCryptoError as exc:
|
||||||
_update_row(ne_id, "fail", str(exc))
|
ctx = {
|
||||||
|
"ip_address": row.ip_address,
|
||||||
|
"port": row.port,
|
||||||
|
"protocol": row.protocol,
|
||||||
|
"device_type": row.device_type,
|
||||||
|
"vendor": row.vendor,
|
||||||
|
"username": row.username,
|
||||||
|
"hop_enabled": bool(row.hop_enabled),
|
||||||
|
"hop_vendor": row.hop_vendor,
|
||||||
|
"hop_host": row.hop_host,
|
||||||
|
"hop_port": row.hop_port,
|
||||||
|
"hop_protocol": row.hop_protocol,
|
||||||
|
"hop_username": row.hop_username,
|
||||||
|
"hop_command_template": row.hop_command_template,
|
||||||
|
"hop_vrf": row.hop_vrf,
|
||||||
|
}
|
||||||
|
detail = _truncate_detail(
|
||||||
|
"\n".join(_connect_context_lines(ctx)) + f"\nresult=fail\nerror=CredentialCryptoError: {exc}"
|
||||||
|
)
|
||||||
|
_update_row(ne_id, "fail", str(exc), detail=detail)
|
||||||
return
|
return
|
||||||
status, message, discovered = _probe_device(creds)
|
status, message, discovered, detail = _probe_device(creds)
|
||||||
_update_row(ne_id, status, message, discovered)
|
_update_row(ne_id, status, message, discovered, detail=detail)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log.exception("connect test failed for %s", ne_id)
|
_log.exception("connect test failed for %s", ne_id)
|
||||||
_update_row(ne_id, "fail", str(exc)[:480])
|
_update_row(ne_id, "fail", str(exc)[:480], detail=_truncate_detail(traceback.format_exc()))
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,7 @@ class ManagedNeOut(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
connect_status: ConnectStatus
|
connect_status: ConnectStatus
|
||||||
connect_message: str
|
connect_message: str
|
||||||
|
connect_detail: str = ""
|
||||||
connect_tested_at: datetime | None
|
connect_tested_at: datetime | None
|
||||||
tags: str
|
tags: str
|
||||||
remark: str
|
remark: str
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from .ne_schemas import (
|
||||||
ManagedNeOut,
|
ManagedNeOut,
|
||||||
ManagedNeUpdate,
|
ManagedNeUpdate,
|
||||||
)
|
)
|
||||||
from .ne_session_factory import default_zte_hop_template
|
from .ne_session_factory import default_hop_command_template
|
||||||
|
|
||||||
IMPORT_COLUMNS = (
|
IMPORT_COLUMNS = (
|
||||||
"device_type",
|
"device_type",
|
||||||
|
|
@ -53,7 +53,7 @@ def _normalize_protocol(protocol: str) -> str:
|
||||||
|
|
||||||
def _normalize_hop_vendor(vendor: str) -> str:
|
def _normalize_hop_vendor(vendor: str) -> str:
|
||||||
v = str(vendor or "zte").strip().lower()
|
v = str(vendor or "zte").strip().lower()
|
||||||
return v if v in ("zte", "linux") else "zte"
|
return v if v in ("zte", "linux", "huawei", "cisco") else "zte"
|
||||||
|
|
||||||
|
|
||||||
def _validate_hop_on_create(body: ManagedNeCreate) -> None:
|
def _validate_hop_on_create(body: ManagedNeCreate) -> None:
|
||||||
|
|
@ -123,6 +123,7 @@ def row_to_out(row: ManagedNE) -> ManagedNeOut:
|
||||||
username=str(row.username or ""),
|
username=str(row.username or ""),
|
||||||
connect_status=status, # type: ignore[arg-type]
|
connect_status=status, # type: ignore[arg-type]
|
||||||
connect_message=str(row.connect_message or "")[:500],
|
connect_message=str(row.connect_message or "")[:500],
|
||||||
|
connect_detail=str(row.connect_detail or "")[:8000],
|
||||||
connect_tested_at=row.connect_tested_at,
|
connect_tested_at=row.connect_tested_at,
|
||||||
tags=str(row.tags or ""),
|
tags=str(row.tags or ""),
|
||||||
remark=str(row.remark or ""),
|
remark=str(row.remark or ""),
|
||||||
|
|
@ -289,8 +290,8 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d
|
||||||
|
|
||||||
hop_vendor = _normalize_hop_vendor(hop.hop_vendor)
|
hop_vendor = _normalize_hop_vendor(hop.hop_vendor)
|
||||||
template = str(hop.hop_command_template or "").strip()
|
template = str(hop.hop_command_template or "").strip()
|
||||||
if hop_vendor == "zte" and not template:
|
if hop_vendor != "linux" and not template:
|
||||||
template = default_zte_hop_template(hop.hop_protocol, hop.hop_vrf)
|
template = default_hop_command_template(hop_vendor, hop.hop_protocol, hop.hop_vrf)
|
||||||
|
|
||||||
ne_ids = [str(x).strip() for x in ids if str(x).strip()]
|
ne_ids = [str(x).strip() for x in ids if str(x).strip()]
|
||||||
if not ne_ids:
|
if not ne_ids:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Netmiko session factory: direct connect, ZTE CLI hop, or Linux SSH bastion."""
|
"""Netmiko session factory: direct connect, vendor CLI hop (ZTE/Huawei/Cisco), or Linux SSH bastion."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -29,11 +29,49 @@ def default_zte_hop_template(protocol: str, vrf: str = "") -> str:
|
||||||
return f"{cmd} {{target_ip}}"
|
return f"{cmd} {{target_ip}}"
|
||||||
|
|
||||||
|
|
||||||
|
def default_cisco_hop_template(protocol: str, vrf: str = "") -> str:
|
||||||
|
"""Cisco CLI jump: ssh -vrf VRF IP; telnet IP [/vrf VRF]."""
|
||||||
|
v = str(vrf or "").strip()
|
||||||
|
if str(protocol or "ssh").strip().lower() == "telnet":
|
||||||
|
if v:
|
||||||
|
return "telnet {target_ip} /vrf {vrf}"
|
||||||
|
return "telnet {target_ip}"
|
||||||
|
if v:
|
||||||
|
return "ssh -vrf {vrf} {target_ip}"
|
||||||
|
return "ssh {target_ip}"
|
||||||
|
|
||||||
|
|
||||||
|
def default_huawei_hop_template(protocol: str, vrf: str = "") -> str:
|
||||||
|
"""Huawei CLI jump: telnet [vpn-instance VRF] IP; stelnet = SSH."""
|
||||||
|
v = str(vrf or "").strip()
|
||||||
|
if str(protocol or "ssh").strip().lower() == "telnet":
|
||||||
|
if v:
|
||||||
|
return "telnet vpn-instance {vrf} {target_ip}"
|
||||||
|
return "telnet {target_ip}"
|
||||||
|
if v:
|
||||||
|
return "stelnet {target_ip} -vpn-instance {vrf}"
|
||||||
|
return "stelnet {target_ip}"
|
||||||
|
|
||||||
|
|
||||||
|
def default_hop_command_template(vendor: str, protocol: str, vrf: str = "") -> str:
|
||||||
|
v = str(vendor or "zte").strip().lower()
|
||||||
|
if v == "huawei":
|
||||||
|
return default_huawei_hop_template(protocol, vrf)
|
||||||
|
if v == "cisco":
|
||||||
|
return default_cisco_hop_template(protocol, vrf)
|
||||||
|
return default_zte_hop_template(protocol, vrf)
|
||||||
|
|
||||||
|
|
||||||
|
def _hop_vendor(creds: dict[str, Any]) -> str:
|
||||||
|
return str(creds.get("hop_vendor") or "zte").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
def render_hop_command(template: str, creds: dict[str, Any]) -> str:
|
def render_hop_command(template: str, creds: dict[str, Any]) -> str:
|
||||||
"""Render hop command from template using whitelisted placeholders only."""
|
"""Render hop command from template using whitelisted placeholders only."""
|
||||||
tpl = str(template or "").strip()
|
tpl = str(template or "").strip()
|
||||||
if not tpl or tpl in _LEGACY_HOP_TEMPLATES:
|
if not tpl or tpl in _LEGACY_HOP_TEMPLATES:
|
||||||
tpl = default_zte_hop_template(
|
tpl = default_hop_command_template(
|
||||||
|
_hop_vendor(creds),
|
||||||
str(creds.get("hop_protocol") or "ssh"),
|
str(creds.get("hop_protocol") or "ssh"),
|
||||||
str(creds.get("hop_vrf") or ""),
|
str(creds.get("hop_vrf") or ""),
|
||||||
)
|
)
|
||||||
|
|
@ -150,7 +188,19 @@ def _interactive_target_auth(conn: ConnectHandler, username: str, password: str)
|
||||||
raise TimeoutError("target_auth_timeout")
|
raise TimeoutError("target_auth_timeout")
|
||||||
|
|
||||||
|
|
||||||
def _connect_via_zte_hop(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler:
|
def _hop_netmiko_device_type(vendor: str, hop_protocol: str) -> str:
|
||||||
|
v = str(vendor or "zte").strip().lower()
|
||||||
|
if v == "huawei":
|
||||||
|
base = "huawei"
|
||||||
|
elif v == "cisco":
|
||||||
|
base = "cisco_ios"
|
||||||
|
else:
|
||||||
|
base = "zte_zxros"
|
||||||
|
return normalize_netmiko_device_type(base, hop_protocol)
|
||||||
|
|
||||||
|
|
||||||
|
def _connect_via_cli_hop(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler:
|
||||||
|
"""Login to ZTE/Huawei/Cisco hop NE, run CLI jump command, then target secondary auth."""
|
||||||
hop_host = str(creds.get("hop_host") or "").strip()
|
hop_host = str(creds.get("hop_host") or "").strip()
|
||||||
hop_user = str(creds.get("hop_username") or "").strip()
|
hop_user = str(creds.get("hop_username") or "").strip()
|
||||||
hop_pass = str(creds.get("hop_password") or "")
|
hop_pass = str(creds.get("hop_password") or "")
|
||||||
|
|
@ -158,7 +208,7 @@ def _connect_via_zte_hop(creds: dict[str, Any], *, session_timeout: int | None =
|
||||||
raise ValueError("hop_credentials_incomplete")
|
raise ValueError("hop_credentials_incomplete")
|
||||||
|
|
||||||
hop_protocol = str(creds.get("hop_protocol") or "ssh")
|
hop_protocol = str(creds.get("hop_protocol") or "ssh")
|
||||||
hop_device_type = normalize_netmiko_device_type("zte_zxros", hop_protocol)
|
hop_device_type = _hop_netmiko_device_type(_hop_vendor(creds), hop_protocol)
|
||||||
hop_dev = _base_connect_kwargs(
|
hop_dev = _base_connect_kwargs(
|
||||||
device_type=hop_device_type,
|
device_type=hop_device_type,
|
||||||
host=hop_host,
|
host=hop_host,
|
||||||
|
|
@ -183,10 +233,6 @@ def _connect_via_zte_hop(creds: dict[str, Any], *, session_timeout: int | None =
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _hop_vendor(creds: dict[str, Any]) -> str:
|
|
||||||
return str(creds.get("hop_vendor") or "zte").strip().lower()
|
|
||||||
|
|
||||||
|
|
||||||
def _connect_via_linux_hop(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler:
|
def _connect_via_linux_hop(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler:
|
||||||
"""SSH to Linux bastion, then direct-tcpip tunnel to target (classic ProxyJump-style)."""
|
"""SSH to Linux bastion, then direct-tcpip tunnel to target (classic ProxyJump-style)."""
|
||||||
hop_host = str(creds.get("hop_host") or "").strip()
|
hop_host = str(creds.get("hop_host") or "").strip()
|
||||||
|
|
@ -267,5 +313,5 @@ def open_netmiko_connection(creds: dict[str, Any], *, session_timeout: int | Non
|
||||||
if creds.get("hop_enabled"):
|
if creds.get("hop_enabled"):
|
||||||
if _hop_vendor(creds) == "linux":
|
if _hop_vendor(creds) == "linux":
|
||||||
return _connect_via_linux_hop(creds, session_timeout=session_timeout)
|
return _connect_via_linux_hop(creds, session_timeout=session_timeout)
|
||||||
return _connect_via_zte_hop(creds, session_timeout=session_timeout)
|
return _connect_via_cli_hop(creds, session_timeout=session_timeout)
|
||||||
return _connect_direct(creds, session_timeout=session_timeout)
|
return _connect_direct(creds, session_timeout=session_timeout)
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,17 @@ class ManagedNeHostnameParseTests(unittest.TestCase):
|
||||||
out = "line1\nZXR10-PE1#"
|
out = "line1\nZXR10-PE1#"
|
||||||
self.assertEqual(parse_hostname_from_output("zte_zxros", "ZTE", out), "ZXR10-PE1#")
|
self.assertEqual(parse_hostname_from_output("zte_zxros", "ZTE", out), "ZXR10-PE1#")
|
||||||
|
|
||||||
|
def test_cisco_hostname(self):
|
||||||
|
out = "hostname R2\nR2#"
|
||||||
|
self.assertEqual(parse_hostname_from_output("cisco_ios", "Cisco", out), "R2")
|
||||||
|
|
||||||
def test_probe_commands(self):
|
def test_probe_commands(self):
|
||||||
self.assertIn("sysname", hostname_probe_command("huawei", "Huawei") or "")
|
self.assertIn("sysname", hostname_probe_command("huawei", "Huawei") or "")
|
||||||
self.assertEqual(hostname_probe_command("zte_zxros", "ZTE"), None)
|
self.assertEqual(hostname_probe_command("zte_zxros", "ZTE"), None)
|
||||||
|
self.assertEqual(
|
||||||
|
hostname_probe_command("cisco_ios", "Cisco"),
|
||||||
|
"show configuration | include hostname",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ManagedNeCryptoTests(unittest.TestCase):
|
class ManagedNeCryptoTests(unittest.TestCase):
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ import type { ReactNode } from "react";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
import {
|
import {
|
||||||
HOP_VENDORS,
|
HOP_VENDORS,
|
||||||
|
defaultHopTemplate,
|
||||||
isAutoHopTemplate,
|
isAutoHopTemplate,
|
||||||
isLinuxHopVendor,
|
isLinuxHopVendor,
|
||||||
patchHopVendorChange,
|
patchHopVendorChange,
|
||||||
zteHopTemplate,
|
|
||||||
type HopVendor,
|
type HopVendor,
|
||||||
} from "../utils/hopProxy";
|
} from "../utils/hopProxy";
|
||||||
|
|
||||||
|
|
@ -27,7 +27,7 @@ export const emptyHopProxyFields = (): HopProxyFieldsState => ({
|
||||||
hop_protocol: "ssh",
|
hop_protocol: "ssh",
|
||||||
hop_username: "",
|
hop_username: "",
|
||||||
hop_password: "",
|
hop_password: "",
|
||||||
hop_command_template: zteHopTemplate("ssh", ""),
|
hop_command_template: defaultHopTemplate("zte", "ssh", ""),
|
||||||
hop_vrf: "",
|
hop_vrf: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -51,10 +51,32 @@ function applyHopTemplate(
|
||||||
vrf: string,
|
vrf: string,
|
||||||
force = false,
|
force = false,
|
||||||
): Partial<HopProxyFieldsState> {
|
): Partial<HopProxyFieldsState> {
|
||||||
if (!force && !isAutoHopTemplate(prev.hop_command_template, prev.hop_protocol, prev.hop_vrf)) {
|
if (!force && !isAutoHopTemplate(prev.hop_command_template, prev.hop_vendor, prev.hop_protocol, prev.hop_vrf)) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return { hop_command_template: zteHopTemplate(protocol, vrf) };
|
return { hop_command_template: defaultHopTemplate(prev.hop_vendor, protocol, vrf) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hopHintKey(vendor: string): string {
|
||||||
|
const v = String(vendor || "").toLowerCase();
|
||||||
|
if (v === "linux") return "managedNe.hop.linuxHint";
|
||||||
|
if (v === "huawei") return "managedNe.hop.huaweiHint";
|
||||||
|
if (v === "cisco") return "managedNe.hop.ciscoHint";
|
||||||
|
return "managedNe.hop.zteHint";
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateHintKey(vendor: string): string {
|
||||||
|
const v = String(vendor || "").toLowerCase();
|
||||||
|
if (v === "huawei") return "managedNe.hop.templateHintHuawei";
|
||||||
|
if (v === "cisco") return "managedNe.hop.templateHintCisco";
|
||||||
|
return "managedNe.hop.templateHint";
|
||||||
|
}
|
||||||
|
|
||||||
|
function vrfLabelKey(vendor: string): string {
|
||||||
|
const v = String(vendor || "").toLowerCase();
|
||||||
|
if (v === "huawei") return "managedNe.hop.vpnInstance";
|
||||||
|
if (v === "cisco") return "managedNe.hop.vrfCisco";
|
||||||
|
return "managedNe.hop.vrf";
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -72,6 +94,7 @@ export function HopProxyFields({
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const linux = isLinuxHopVendor(value.hop_vendor);
|
const linux = isLinuxHopVendor(value.hop_vendor);
|
||||||
|
const huawei = value.hop_vendor === "huawei";
|
||||||
|
|
||||||
const set = (patch: Partial<HopProxyFieldsState>) => onChange(patch);
|
const set = (patch: Partial<HopProxyFieldsState>) => onChange(patch);
|
||||||
|
|
||||||
|
|
@ -92,9 +115,7 @@ export function HopProxyFields({
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span className="form-field-hint">
|
<span className="form-field-hint">{t(hopHintKey(value.hop_vendor))}</span>
|
||||||
{linux ? t("managedNe.hop.linuxHint") : t("managedNe.hop.zteHint")}
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<FormLabel required>{t("managedNe.hop.host")}</FormLabel>
|
<FormLabel required>{t("managedNe.hop.host")}</FormLabel>
|
||||||
|
|
@ -118,7 +139,7 @@ export function HopProxyFields({
|
||||||
set({ hop_protocol, ...applyHopTemplate(value, hop_protocol, value.hop_vrf) });
|
set({ hop_protocol, ...applyHopTemplate(value, hop_protocol, value.hop_vrf) });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="ssh">ssh</option>
|
<option value="ssh">{huawei ? t("managedNe.hop.protocolSshStelnet") : "ssh"}</option>
|
||||||
<option value="telnet">telnet</option>
|
<option value="telnet">telnet</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -144,7 +165,7 @@ export function HopProxyFields({
|
||||||
{!linux ? (
|
{!linux ? (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label>
|
||||||
<FormLabel>{t("managedNe.hop.vrf")}</FormLabel>
|
<FormLabel>{t(vrfLabelKey(value.hop_vendor))}</FormLabel>
|
||||||
<input
|
<input
|
||||||
value={value.hop_vrf}
|
value={value.hop_vrf}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|
@ -158,9 +179,9 @@ export function HopProxyFields({
|
||||||
<input
|
<input
|
||||||
value={value.hop_command_template}
|
value={value.hop_command_template}
|
||||||
onChange={(e) => set({ hop_command_template: e.target.value })}
|
onChange={(e) => set({ hop_command_template: e.target.value })}
|
||||||
placeholder={zteHopTemplate(value.hop_protocol, value.hop_vrf)}
|
placeholder={defaultHopTemplate(value.hop_vendor, value.hop_protocol, value.hop_vrf)}
|
||||||
/>
|
/>
|
||||||
<span className="form-field-hint">{t("managedNe.hop.templateHint")}</span>
|
<span className="form-field-hint">{t(templateHintKey(value.hop_vendor))}</span>
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,12 @@ const en = {
|
||||||
run: "Connectivity test",
|
run: "Connectivity test",
|
||||||
running: "Testing…",
|
running: "Testing…",
|
||||||
submitted: "Submitted tests for {{n}} device(s)",
|
submitted: "Submitted tests for {{n}} device(s)",
|
||||||
|
retest: "Test again",
|
||||||
},
|
},
|
||||||
|
connectDetail: "Details",
|
||||||
|
connectDetailTitle: "Connectivity test log",
|
||||||
|
connectDetailEmpty:
|
||||||
|
"No log yet. Run a connectivity test first; failures store full errors and hop context (passwords excluded).",
|
||||||
importResult: {
|
importResult: {
|
||||||
done: "Import done: {{inserted}} inserted, {{updated}} updated, {{failed}} failed row(s)",
|
done: "Import done: {{inserted}} inserted, {{updated}} updated, {{failed}} failed row(s)",
|
||||||
},
|
},
|
||||||
|
|
@ -187,21 +192,34 @@ const en = {
|
||||||
enable: "Connect to target via jump host",
|
enable: "Connect to target via jump host",
|
||||||
vendor: {
|
vendor: {
|
||||||
zte: "ZTE device (CLI jump)",
|
zte: "ZTE device (CLI jump)",
|
||||||
|
huawei: "Huawei device (CLI jump)",
|
||||||
|
cisco: "Cisco device (CLI jump)",
|
||||||
linux: "Linux server (SSH tunnel)",
|
linux: "Linux server (SSH tunnel)",
|
||||||
},
|
},
|
||||||
zteHint: "Run ssh/telnet on the ZTE device to reach the target; target credentials use secondary auth.",
|
zteHint: "Run ssh/telnet on the ZTE device to reach the target; target credentials use secondary auth.",
|
||||||
|
huaweiHint: "Run telnet / stelnet (SSH) on the Huawei hop; stelnet is SSH. Target credentials use secondary auth.",
|
||||||
|
ciscoHint: "Run Cisco ssh -vrf / telnet /vrf jump commands; target credentials use secondary auth.",
|
||||||
linuxHint: "SSH to the Linux bastion, then direct-tcpip tunnel to target IP:port (ProxyJump-style).",
|
linuxHint: "SSH to the Linux bastion, then direct-tcpip tunnel to target IP:port (ProxyJump-style).",
|
||||||
host: "Jump host",
|
host: "Jump host",
|
||||||
port: "Jump port",
|
port: "Jump port",
|
||||||
protocol: "Jump protocol",
|
protocol: "Jump protocol",
|
||||||
|
protocolSshStelnet: "ssh (stelnet)",
|
||||||
username: "Jump username",
|
username: "Jump username",
|
||||||
password: "Jump password",
|
password: "Jump password",
|
||||||
vrf: "Mgmt VRF (optional)",
|
vrf: "Mgmt VRF (optional)",
|
||||||
|
vpnInstance: "VPN-Instance (optional)",
|
||||||
|
vrfCisco: "VRF (optional, e.g. MGMT)",
|
||||||
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.",
|
||||||
|
templateHintHuawei:
|
||||||
|
"Huawei CLI: telnet {target_ip}, telnet vpn-instance {vrf} {target_ip}, stelnet {target_ip}, stelnet {target_ip} -vpn-instance {vrf}. SSH protocol maps to stelnet. Auto-suggested when left blank.",
|
||||||
|
templateHintCisco:
|
||||||
|
"Cisco CLI: ssh {target_ip}, ssh -vrf {vrf} {target_ip}, telnet {target_ip}, telnet {target_ip} /vrf {vrf}. Auto-suggested when left blank.",
|
||||||
badge: {
|
badge: {
|
||||||
zte: "ZTE hop",
|
zte: "ZTE hop",
|
||||||
|
huawei: "Huawei hop",
|
||||||
|
cisco: "Cisco hop",
|
||||||
linux: "Linux hop",
|
linux: "Linux hop",
|
||||||
},
|
},
|
||||||
hostRequired: "Jump host is required",
|
hostRequired: "Jump host is required",
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,11 @@ const zh = {
|
||||||
run: "连通性测试",
|
run: "连通性测试",
|
||||||
running: "测试中…",
|
running: "测试中…",
|
||||||
submitted: "已提交 {{n}} 台设备测试",
|
submitted: "已提交 {{n}} 台设备测试",
|
||||||
|
retest: "重新测试",
|
||||||
},
|
},
|
||||||
|
connectDetail: "详情",
|
||||||
|
connectDetailTitle: "连通性测试日志",
|
||||||
|
connectDetailEmpty: "暂无日志。请先执行连通性测试;失败时会记录完整错误与跳板上下文(不含密码)。",
|
||||||
importResult: {
|
importResult: {
|
||||||
done: "导入完成:新增 {{inserted}},更新 {{updated}},失败 {{failed}} 行",
|
done: "导入完成:新增 {{inserted}},更新 {{updated}},失败 {{failed}} 行",
|
||||||
},
|
},
|
||||||
|
|
@ -186,21 +190,34 @@ const zh = {
|
||||||
enable: "经跳板登录目标网元",
|
enable: "经跳板登录目标网元",
|
||||||
vendor: {
|
vendor: {
|
||||||
zte: "ZTE 设备(CLI 跳登)",
|
zte: "ZTE 设备(CLI 跳登)",
|
||||||
|
huawei: "华为设备(CLI 跳登)",
|
||||||
|
cisco: "思科设备(CLI 跳登)",
|
||||||
linux: "Linux 服务器(SSH 隧道)",
|
linux: "Linux 服务器(SSH 隧道)",
|
||||||
},
|
},
|
||||||
zteHint: "在 ZTE 设备上执行 ssh/telnet 命令跳转到目标,目标账号由二次认证输入。",
|
zteHint: "在 ZTE 设备上执行 ssh/telnet 命令跳转到目标,目标账号由二次认证输入。",
|
||||||
|
huaweiHint: "在华为设备上执行 telnet / stelnet(SSH)跳登;stelnet 即 SSH。目标账号由二次认证输入。",
|
||||||
|
ciscoHint: "在思科设备上执行 ssh -vrf / telnet /vrf 跳登,目标账号由二次认证输入。",
|
||||||
linuxHint: "先 SSH 登录 Linux 跳板,经 direct-tcpip 隧道连接目标 IP:端口(等同 ProxyJump)。",
|
linuxHint: "先 SSH 登录 Linux 跳板,经 direct-tcpip 隧道连接目标 IP:端口(等同 ProxyJump)。",
|
||||||
host: "跳板地址",
|
host: "跳板地址",
|
||||||
port: "跳板端口",
|
port: "跳板端口",
|
||||||
protocol: "跳板协议",
|
protocol: "跳板协议",
|
||||||
|
protocolSshStelnet: "ssh(stelnet)",
|
||||||
username: "跳板用户名",
|
username: "跳板用户名",
|
||||||
password: "跳板密码",
|
password: "跳板密码",
|
||||||
vrf: "管理 VRF(可选)",
|
vrf: "管理 VRF(可选)",
|
||||||
|
vpnInstance: "VPN-Instance(可选)",
|
||||||
|
vrfCisco: "VRF(可选,如 MGMT)",
|
||||||
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 自动推荐;留空时后端同样规则。目标账号密码由二次认证提示输入。",
|
||||||
|
templateHintHuawei:
|
||||||
|
"华为常用:telnet {target_ip}、telnet vpn-instance {vrf} {target_ip}、stelnet {target_ip}、stelnet {target_ip} -vpn-instance {vrf}。SSH 协议对应 stelnet。留空时按协议与 VPN-Instance 自动推荐。",
|
||||||
|
templateHintCisco:
|
||||||
|
"思科常用:ssh {target_ip}、ssh -vrf {vrf} {target_ip}、telnet {target_ip}、telnet {target_ip} /vrf {vrf}。留空时按协议与 VRF 自动推荐。",
|
||||||
badge: {
|
badge: {
|
||||||
zte: "ZTE跳板",
|
zte: "ZTE跳板",
|
||||||
|
huawei: "华为跳板",
|
||||||
|
cisco: "思科跳板",
|
||||||
linux: "Linux跳板",
|
linux: "Linux跳板",
|
||||||
},
|
},
|
||||||
hostRequired: "请填写跳板地址",
|
hostRequired: "请填写跳板地址",
|
||||||
|
|
|
||||||
|
|
@ -835,6 +835,31 @@ pre {
|
||||||
box-shadow: 0 16px 48px rgba(15, 23, 42, 0.2);
|
box-shadow: 0 16px 48px rgba(15, 23, 42, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal--wide {
|
||||||
|
width: min(920px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connect-detail-summary {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connect-log {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 12px;
|
||||||
|
max-height: min(52vh, 480px);
|
||||||
|
overflow: auto;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.modal h3 {
|
.modal h3 {
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
batchApplyHopManagedNe,
|
batchApplyHopManagedNe,
|
||||||
|
|
@ -19,8 +19,12 @@ import { useToast } from "../hooks/useToast";
|
||||||
import type { ManagedNeItem } from "../types";
|
import type { ManagedNeItem } from "../types";
|
||||||
import { pageCount } from "../utils/display";
|
import { pageCount } from "../utils/display";
|
||||||
import { formatSystemTime } from "../utils/time";
|
import { formatSystemTime } from "../utils/time";
|
||||||
import { isAutoHopTemplate, patchHopVendorChange, zteHopTemplate } from "../utils/hopProxy";
|
import {
|
||||||
import type { HopVendor } from "../utils/hopProxy";
|
defaultHopTemplate,
|
||||||
|
isAutoHopTemplate,
|
||||||
|
patchHopVendorChange,
|
||||||
|
type HopVendor,
|
||||||
|
} from "../utils/hopProxy";
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -62,15 +66,15 @@ const emptyForm = (): FormState => ({
|
||||||
hop_protocol: "ssh",
|
hop_protocol: "ssh",
|
||||||
hop_username: "",
|
hop_username: "",
|
||||||
hop_password: "",
|
hop_password: "",
|
||||||
hop_command_template: zteHopTemplate("ssh", ""),
|
hop_command_template: defaultHopTemplate("zte", "ssh", ""),
|
||||||
hop_vrf: "",
|
hop_vrf: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
function applyHopTemplate(prev: FormState, protocol: string, vrf: string, force = false): Partial<FormState> {
|
function applyHopTemplate(prev: FormState, protocol: string, vrf: string, force = false): Partial<FormState> {
|
||||||
if (!force && !isAutoHopTemplate(prev.hop_command_template, prev.hop_protocol, prev.hop_vrf)) {
|
if (!force && !isAutoHopTemplate(prev.hop_command_template, prev.hop_vendor, prev.hop_protocol, prev.hop_vrf)) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return { hop_command_template: zteHopTemplate(protocol, vrf) };
|
return { hop_command_template: defaultHopTemplate(prev.hop_vendor, protocol, vrf) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
|
function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
|
||||||
|
|
@ -111,6 +115,7 @@ export function NePage() {
|
||||||
const [editing, setEditing] = useState<ManagedNeItem | null>(null);
|
const [editing, setEditing] = useState<ManagedNeItem | null>(null);
|
||||||
const [form, setForm] = useState<FormState>(emptyForm);
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
const [batchHop, setBatchHop] = useState<HopProxyFieldsState>(emptyHopProxyFields);
|
const [batchHop, setBatchHop] = useState<HopProxyFieldsState>(emptyHopProxyFields);
|
||||||
|
const [connectDetailRow, setConnectDetailRow] = useState<ManagedNeItem | null>(null);
|
||||||
|
|
||||||
const metaQuery = useQuery({
|
const metaQuery = useQuery({
|
||||||
queryKey: queryKeys.managedNeMeta,
|
queryKey: queryKeys.managedNeMeta,
|
||||||
|
|
@ -134,6 +139,20 @@ export function NePage() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connectDetailRow) return;
|
||||||
|
const updated = listQuery.data?.items?.find((x) => x.id === connectDetailRow.id);
|
||||||
|
if (!updated) return;
|
||||||
|
if (
|
||||||
|
updated.connect_status !== connectDetailRow.connect_status ||
|
||||||
|
updated.connect_message !== connectDetailRow.connect_message ||
|
||||||
|
updated.connect_detail !== connectDetailRow.connect_detail ||
|
||||||
|
updated.connect_tested_at !== connectDetailRow.connect_tested_at
|
||||||
|
) {
|
||||||
|
setConnectDetailRow(updated);
|
||||||
|
}
|
||||||
|
}, [listQuery.data, connectDetailRow]);
|
||||||
|
|
||||||
const total = listQuery.data?.total ?? 0;
|
const total = listQuery.data?.total ?? 0;
|
||||||
const pages = pageCount(total, pageSize);
|
const pages = pageCount(total, pageSize);
|
||||||
const perPage = (n: number) => t("common.perPage", { n });
|
const perPage = (n: number) => t("common.perPage", { n });
|
||||||
|
|
@ -279,7 +298,9 @@ export function NePage() {
|
||||||
tags: row.tags,
|
tags: row.tags,
|
||||||
remark: row.remark,
|
remark: row.remark,
|
||||||
hop_enabled: row.hop_enabled,
|
hop_enabled: row.hop_enabled,
|
||||||
hop_vendor: (row.hop_vendor === "linux" ? "linux" : "zte") as HopVendor,
|
hop_vendor: (["linux", "huawei", "cisco", "zte"].includes(row.hop_vendor)
|
||||||
|
? row.hop_vendor
|
||||||
|
: "zte") as HopVendor,
|
||||||
hop_host: row.hop_host,
|
hop_host: row.hop_host,
|
||||||
hop_port: row.hop_port,
|
hop_port: row.hop_port,
|
||||||
hop_protocol: row.hop_protocol,
|
hop_protocol: row.hop_protocol,
|
||||||
|
|
@ -287,11 +308,12 @@ export function NePage() {
|
||||||
hop_password: "",
|
hop_password: "",
|
||||||
hop_command_template: isAutoHopTemplate(
|
hop_command_template: isAutoHopTemplate(
|
||||||
row.hop_command_template,
|
row.hop_command_template,
|
||||||
|
row.hop_vendor,
|
||||||
row.hop_protocol,
|
row.hop_protocol,
|
||||||
row.hop_vrf,
|
row.hop_vrf,
|
||||||
)
|
)
|
||||||
? zteHopTemplate(row.hop_protocol, row.hop_vrf)
|
? defaultHopTemplate(row.hop_vendor, row.hop_protocol, row.hop_vrf)
|
||||||
: row.hop_command_template || zteHopTemplate(row.hop_protocol, row.hop_vrf),
|
: row.hop_command_template || defaultHopTemplate(row.hop_vendor, row.hop_protocol, row.hop_vrf),
|
||||||
hop_vrf: row.hop_vrf,
|
hop_vrf: row.hop_vrf,
|
||||||
});
|
});
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
|
|
@ -466,7 +488,9 @@ export function NePage() {
|
||||||
className="table-tag"
|
className="table-tag"
|
||||||
title={`${row.hop_host}:${row.hop_port} (${row.hop_vendor})`}
|
title={`${row.hop_host}:${row.hop_port} (${row.hop_vendor})`}
|
||||||
>
|
>
|
||||||
{t(`managedNe.hop.badge.${row.hop_vendor === "linux" ? "linux" : "zte"}`)}
|
{t(
|
||||||
|
`managedNe.hop.badge.${["linux", "huawei", "cisco", "zte"].includes(row.hop_vendor) ? row.hop_vendor : "zte"}`,
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -485,12 +509,20 @@ export function NePage() {
|
||||||
: t("common.empty")}
|
: t("common.empty")}
|
||||||
</td>
|
</td>
|
||||||
<td className="table-actions">
|
<td className="table-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link-btn"
|
||||||
|
onClick={() => setConnectDetailRow(row)}
|
||||||
|
disabled={!row.connect_tested_at && !row.connect_message && !row.connect_detail}
|
||||||
|
>
|
||||||
|
{t("managedNe.connectDetail")}
|
||||||
|
</button>
|
||||||
<button type="button" className="link-btn" onClick={() => openEdit(row)}>
|
<button type="button" className="link-btn" onClick={() => openEdit(row)}>
|
||||||
{t("managedNe.edit")}
|
{t("managedNe.edit")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="link-btn"
|
className="link-btn link-btn--danger"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm(t("managedNe.confirmDelete"))) deleteMutation.mutate(row.id);
|
if (window.confirm(t("managedNe.confirmDelete"))) deleteMutation.mutate(row.id);
|
||||||
}}
|
}}
|
||||||
|
|
@ -706,6 +738,48 @@ export function NePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{connectDetailRow ? (
|
||||||
|
<div className="modal-backdrop" role="presentation" onClick={() => setConnectDetailRow(null)}>
|
||||||
|
<div className="modal modal--wide" role="dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{t("managedNe.connectDetailTitle")}</h3>
|
||||||
|
<p className="form-hint">
|
||||||
|
{connectDetailRow.name || connectDetailRow.ip_address} · {connectDetailRow.ip_address}:
|
||||||
|
{connectDetailRow.port}/{connectDetailRow.protocol}
|
||||||
|
{connectDetailRow.connect_tested_at
|
||||||
|
? ` · ${formatSystemTime(connectDetailRow.connect_tested_at, { assumeUtcNaive: true })}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className={`conn-pill conn-pill--${connectPillLevel(connectDetailRow.connect_status)}`}>
|
||||||
|
{connectDetailRow.connect_status}
|
||||||
|
</span>
|
||||||
|
{connectDetailRow.connect_message ? (
|
||||||
|
<span className="connect-detail-summary"> — {connectDetailRow.connect_message}</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<pre className="connect-log">
|
||||||
|
{connectDetailRow.connect_detail?.trim() ||
|
||||||
|
connectDetailRow.connect_message?.trim() ||
|
||||||
|
t("managedNe.connectDetailEmpty")}
|
||||||
|
</pre>
|
||||||
|
<div className="modal__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={connectMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
connectMutation.mutate([connectDetailRow.id]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.retest")}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setConnectDetailRow(null)}>
|
||||||
|
{t("managedNe.form.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ export type ManagedNeItem = {
|
||||||
username: string;
|
username: string;
|
||||||
connect_status: ConnectStatus;
|
connect_status: ConnectStatus;
|
||||||
connect_message: string;
|
connect_message: string;
|
||||||
|
connect_detail: string;
|
||||||
connect_tested_at: string | null;
|
connect_tested_at: string | null;
|
||||||
tags: string;
|
tags: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,43 @@
|
||||||
import { isAutoHopTemplate, zteHopTemplate } from "./zteHop";
|
/** Jump-host (hop) templates per vendor. */
|
||||||
|
|
||||||
export type HopVendor = "zte" | "linux";
|
import { ciscoHopTemplate, huaweiHopTemplate, isAutoHopTemplate, zteHopTemplate } from "./zteHop";
|
||||||
|
|
||||||
export const HOP_VENDORS: HopVendor[] = ["zte", "linux"];
|
export type HopVendor = "zte" | "huawei" | "cisco" | "linux";
|
||||||
|
|
||||||
|
export const HOP_VENDORS: HopVendor[] = ["zte", "huawei", "cisco", "linux"];
|
||||||
|
|
||||||
export function isLinuxHopVendor(vendor: string): boolean {
|
export function isLinuxHopVendor(vendor: string): boolean {
|
||||||
return String(vendor || "").toLowerCase() === "linux";
|
return String(vendor || "").toLowerCase() === "linux";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCliHopVendor(vendor: string): boolean {
|
||||||
|
const v = String(vendor || "").toLowerCase();
|
||||||
|
return v === "zte" || v === "huawei" || v === "cisco";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultHopTemplate(vendor: string, protocol: string, vrf: string): string {
|
||||||
|
const v = String(vendor || "zte").toLowerCase();
|
||||||
|
if (v === "huawei") return huaweiHopTemplate(protocol, vrf);
|
||||||
|
if (v === "cisco") return ciscoHopTemplate(protocol, vrf);
|
||||||
|
if (v === "linux") return "";
|
||||||
|
return zteHopTemplate(protocol, vrf);
|
||||||
|
}
|
||||||
|
|
||||||
export function patchHopVendorChange(
|
export function patchHopVendorChange(
|
||||||
vendor: HopVendor,
|
vendor: HopVendor,
|
||||||
prev: { hop_protocol: string; hop_vrf: string; hop_command_template: string },
|
prev: { hop_protocol: string; hop_vrf: string; hop_command_template: string; hop_vendor?: string },
|
||||||
): { hop_vendor: HopVendor; hop_protocol: string; hop_vrf: string; hop_command_template: string } {
|
): { hop_vendor: HopVendor; hop_protocol: string; hop_vrf: string; hop_command_template: string } {
|
||||||
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: "" };
|
||||||
}
|
}
|
||||||
|
const protocol = prev.hop_protocol || "ssh";
|
||||||
|
const vrf = prev.hop_vrf || "";
|
||||||
return {
|
return {
|
||||||
hop_vendor: "zte",
|
hop_vendor: vendor,
|
||||||
hop_protocol: prev.hop_protocol || "ssh",
|
hop_protocol: protocol,
|
||||||
hop_vrf: prev.hop_vrf,
|
hop_vrf: vrf,
|
||||||
hop_command_template: zteHopTemplate(prev.hop_protocol || "ssh", prev.hop_vrf),
|
hop_command_template: defaultHopTemplate(vendor, protocol, vrf),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { isAutoHopTemplate, zteHopTemplate };
|
export { ciscoHopTemplate, huaweiHopTemplate, isAutoHopTemplate, zteHopTemplate };
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
/** ZTE device CLI jump commands: ssh/telnet <ip> [vrf <name>]. */
|
/** Vendor CLI jump command templates (placeholders). */
|
||||||
|
|
||||||
const LEGACY_HOP_TEMPLATES = new Set([
|
const LEGACY_HOP_TEMPLATES = new Set([
|
||||||
"ssh {target_user}@{target_ip}",
|
"ssh {target_user}@{target_ip}",
|
||||||
|
|
@ -12,8 +12,35 @@ export function zteHopTemplate(protocol: string, vrf: string): string {
|
||||||
return v ? `${cmd} {target_ip} vrf {vrf}` : `${cmd} {target_ip}`;
|
return v ? `${cmd} {target_ip} vrf {vrf}` : `${cmd} {target_ip}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAutoHopTemplate(template: string, protocol: string, vrf: string): boolean {
|
/** Cisco: ssh -vrf VRF IP; telnet IP [/vrf VRF]. */
|
||||||
|
export function ciscoHopTemplate(protocol: string, vrf: string): string {
|
||||||
|
const v = String(vrf || "").trim();
|
||||||
|
if (String(protocol || "ssh").toLowerCase() === "telnet") {
|
||||||
|
return v ? `telnet {target_ip} /vrf {vrf}` : `telnet {target_ip}`;
|
||||||
|
}
|
||||||
|
return v ? `ssh -vrf {vrf} {target_ip}` : `ssh {target_ip}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Huawei: telnet [vpn-instance VRF] IP; SSH uses stelnet. */
|
||||||
|
export function huaweiHopTemplate(protocol: string, vrf: string): string {
|
||||||
|
const v = String(vrf || "").trim();
|
||||||
|
if (String(protocol || "ssh").toLowerCase() === "telnet") {
|
||||||
|
return v ? `telnet vpn-instance {vrf} {target_ip}` : `telnet {target_ip}`;
|
||||||
|
}
|
||||||
|
return v ? `stelnet {target_ip} -vpn-instance {vrf}` : `stelnet {target_ip}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAutoHopTemplate(
|
||||||
|
template: string,
|
||||||
|
vendor: string,
|
||||||
|
protocol: string,
|
||||||
|
vrf: string,
|
||||||
|
): boolean {
|
||||||
const t = String(template || "").trim();
|
const t = String(template || "").trim();
|
||||||
if (!t || LEGACY_HOP_TEMPLATES.has(t)) return true;
|
if (!t || LEGACY_HOP_TEMPLATES.has(t)) return true;
|
||||||
|
const v = String(vendor || "zte").toLowerCase();
|
||||||
|
if (v === "huawei") return t === huaweiHopTemplate(protocol, vrf);
|
||||||
|
if (v === "cisco") return t === ciscoHopTemplate(protocol, vrf);
|
||||||
|
if (v === "linux") return t === "";
|
||||||
return t === zteHopTemplate(protocol, vrf);
|
return t === zteHopTemplate(protocol, vrf);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue