mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +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_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 connect_detail TEXT DEFAULT ''")
|
||||
conn.exec_driver_sql(
|
||||
"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="")
|
||||
connect_status: Mapped[str] = mapped_column(String(32), default="unknown", index=True)
|
||||
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)
|
||||
site: Mapped[str] = mapped_column(String(256), default="")
|
||||
tags: Mapped[str] = mapped_column(String(512), default="")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import re
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
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")
|
||||
_executor: ThreadPoolExecutor | None = None
|
||||
_DETAIL_MAX = 8000
|
||||
|
||||
|
||||
def _executor_pool() -> ThreadPoolExecutor:
|
||||
|
|
@ -25,10 +27,39 @@ def _executor_pool() -> ThreadPoolExecutor:
|
|||
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:
|
||||
"""
|
||||
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()
|
||||
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":
|
||||
return "show system host-name"
|
||||
if "cisco" in dt or v == "cisco":
|
||||
return "show hostname"
|
||||
return "show configuration | include hostname"
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -68,12 +99,15 @@ def parse_hostname_from_output(
|
|||
return m.group(1).strip().rstrip(";")
|
||||
|
||||
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()]
|
||||
for ln in reversed(lines):
|
||||
if ln.startswith("%") or "invalid" in ln.lower():
|
||||
continue
|
||||
token = ln.split()[0].strip("<>[]")
|
||||
if token:
|
||||
if token and token.lower() != "hostname":
|
||||
return token
|
||||
|
||||
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:
|
||||
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"):
|
||||
hop_v = str(creds.get("hop_vendor") or "zte").lower()
|
||||
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":
|
||||
return "hop_connect_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
|
||||
|
||||
|
||||
def _probe_device(creds: dict[str, Any]) -> tuple[str, str, str | None]:
|
||||
"""Login via Netmiko, probe hostname, return (status, message, discovered_name)."""
|
||||
def _format_failure_detail(creds: dict[str, Any], exc: BaseException) -> str:
|
||||
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 "")
|
||||
session_timeout = 180 if creds.get("hop_enabled") else 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)
|
||||
output = ""
|
||||
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)
|
||||
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:
|
||||
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)
|
||||
if fallback:
|
||||
return "pass", f"connected: {fallback}", fallback
|
||||
return "pass", "connected", None
|
||||
msg = f"connected: {fallback}"
|
||||
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:
|
||||
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:
|
||||
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()
|
||||
try:
|
||||
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
|
||||
row.connect_status = status
|
||||
row.connect_message = str(message or "")[:500]
|
||||
row.connect_detail = _truncate_detail(detail)
|
||||
row.connect_tested_at = datetime.utcnow()
|
||||
if discovered_name:
|
||||
row.name = discovered_name[:256]
|
||||
|
|
@ -173,18 +289,38 @@ def _run_single(ne_id: str) -> None:
|
|||
return
|
||||
row.connect_status = "testing"
|
||||
row.connect_message = ""
|
||||
row.connect_detail = ""
|
||||
row.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
try:
|
||||
creds = get_device_credentials(row)
|
||||
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
|
||||
status, message, discovered = _probe_device(creds)
|
||||
_update_row(ne_id, status, message, discovered)
|
||||
status, message, discovered, detail = _probe_device(creds)
|
||||
_update_row(ne_id, status, message, discovered, detail=detail)
|
||||
except Exception as exc:
|
||||
_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:
|
||||
db.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ class ManagedNeOut(BaseModel):
|
|||
username: str
|
||||
connect_status: ConnectStatus
|
||||
connect_message: str
|
||||
connect_detail: str = ""
|
||||
connect_tested_at: datetime | None
|
||||
tags: str
|
||||
remark: str
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from .ne_schemas import (
|
|||
ManagedNeOut,
|
||||
ManagedNeUpdate,
|
||||
)
|
||||
from .ne_session_factory import default_zte_hop_template
|
||||
from .ne_session_factory import default_hop_command_template
|
||||
|
||||
IMPORT_COLUMNS = (
|
||||
"device_type",
|
||||
|
|
@ -53,7 +53,7 @@ def _normalize_protocol(protocol: str) -> str:
|
|||
|
||||
def _normalize_hop_vendor(vendor: str) -> str:
|
||||
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:
|
||||
|
|
@ -123,6 +123,7 @@ def row_to_out(row: ManagedNE) -> ManagedNeOut:
|
|||
username=str(row.username or ""),
|
||||
connect_status=status, # type: ignore[arg-type]
|
||||
connect_message=str(row.connect_message or "")[:500],
|
||||
connect_detail=str(row.connect_detail or "")[:8000],
|
||||
connect_tested_at=row.connect_tested_at,
|
||||
tags=str(row.tags 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)
|
||||
template = str(hop.hop_command_template or "").strip()
|
||||
if hop_vendor == "zte" and not template:
|
||||
template = default_zte_hop_template(hop.hop_protocol, hop.hop_vrf)
|
||||
if hop_vendor != "linux" and not template:
|
||||
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()]
|
||||
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
|
||||
|
||||
|
|
@ -29,11 +29,49 @@ def default_zte_hop_template(protocol: str, vrf: str = "") -> str:
|
|||
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:
|
||||
"""Render hop command from template using whitelisted placeholders only."""
|
||||
tpl = str(template or "").strip()
|
||||
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_vrf") or ""),
|
||||
)
|
||||
|
|
@ -150,7 +188,19 @@ def _interactive_target_auth(conn: ConnectHandler, username: str, password: str)
|
|||
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_user = str(creds.get("hop_username") or "").strip()
|
||||
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")
|
||||
|
||||
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(
|
||||
device_type=hop_device_type,
|
||||
host=hop_host,
|
||||
|
|
@ -183,10 +233,6 @@ def _connect_via_zte_hop(creds: dict[str, Any], *, session_timeout: int | None =
|
|||
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:
|
||||
"""SSH to Linux bastion, then direct-tcpip tunnel to target (classic ProxyJump-style)."""
|
||||
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 _hop_vendor(creds) == "linux":
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue