From d3eae3351ccc268cee472b0d078b6e1c89798e57 Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 28 May 2026 17:38:37 +0800 Subject: [PATCH] feat(ne): add Linux SSH bastion hop type Support hop_vendor=linux via Paramiko direct-tcpip tunnel; ZTE CLI hop unchanged. UI hop type selector and distinct list badges. Co-authored-by: Cursor --- netx_api/ne_collect_runner.py | 7 +- netx_api/ne_connect.py | 13 ++-- netx_api/ne_service.py | 7 +- netx_api/ne_session_factory.py | 84 ++++++++++++++++++++- web/src/components/HopProxyFields.tsx | 101 +++++++++++++++++--------- web/src/i18n/en.ts | 14 +++- web/src/i18n/zh.ts | 14 +++- web/src/pages/NePage.tsx | 25 +++++-- web/src/utils/hopProxy.ts | 26 +++++++ 9 files changed, 232 insertions(+), 59 deletions(-) create mode 100644 web/src/utils/hopProxy.ts diff --git a/netx_api/ne_collect_runner.py b/netx_api/ne_collect_runner.py index 690e5d4..f90cc72 100644 --- a/netx_api/ne_collect_runner.py +++ b/netx_api/ne_collect_runner.py @@ -16,7 +16,7 @@ from .models import ManagedNE, NeCollectionJob, NeCollectionRun from .ne_collection_paths import clear_run_output_files, run_output_dir from .ne_crypto import CredentialCryptoError from .ne_service import get_device_credentials -from .ne_session_factory import open_netmiko_connection +from .ne_session_factory import close_netmiko_connection, open_netmiko_connection _log = logging.getLogger("netx.ne.collect") _executor: ThreadPoolExecutor | None = None @@ -57,10 +57,7 @@ def _collect_on_device(creds: dict[str, Any], commands: list[str]) -> str: chunks.append("\n") return "".join(chunks) finally: - try: - conn.disconnect() - except Exception: - pass + close_netmiko_connection(conn) def _collect_with_timeout(creds: dict[str, Any], commands: list[str]) -> str: diff --git a/netx_api/ne_connect.py b/netx_api/ne_connect.py index ba3eb2b..5a43188 100644 --- a/netx_api/ne_connect.py +++ b/netx_api/ne_connect.py @@ -11,7 +11,7 @@ from .db import SessionLocal from .models import ManagedNE from .ne_crypto import CredentialCryptoError from .ne_service import get_device_credentials -from .ne_session_factory import open_netmiko_connection +from .ne_session_factory import close_netmiko_connection, open_netmiko_connection _log = logging.getLogger("netx.ne.connect") _executor: ThreadPoolExecutor | None = None @@ -104,6 +104,7 @@ def _classify_connect_error(creds: dict[str, Any], exc: BaseException) -> str: raw = str(exc).lower() detail = str(exc).split("\n")[0][:480] 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: return detail if "target_auth_timeout" in raw: @@ -112,7 +113,9 @@ def _classify_connect_error(creds: dict[str, Any], exc: BaseException) -> str: if "hop_host" in raw or str(creds.get("hop_host") or "") in raw: return "hop_auth_failed: " + detail return "target_auth_failed: " + detail - if "timed out" in raw or "timeout" in raw: + if "timed out" in raw or "timeout" in raw or "hop_connect_failed" in raw: + return "hop_connect_failed: " + detail + if hop_v == "linux": return "hop_connect_failed: " + detail return "hop_command_failed: " + detail return detail @@ -142,11 +145,7 @@ def _probe_device(creds: dict[str, Any]) -> tuple[str, str, str | None]: except Exception as exc: return "fail", _classify_connect_error(creds, exc), None finally: - if conn is not None: - try: - conn.disconnect() - except Exception: - pass + close_netmiko_connection(conn) def _update_row(ne_id: str, status: str, message: str, discovered_name: str | None = None) -> None: diff --git a/netx_api/ne_service.py b/netx_api/ne_service.py index 2920f06..80eccce 100644 --- a/netx_api/ne_service.py +++ b/netx_api/ne_service.py @@ -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",) else "zte" + return v if v in ("zte", "linux") else "zte" def _validate_hop_on_create(body: ManagedNeCreate) -> None: @@ -287,8 +287,9 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d if not hop_pass: raise HTTPException(status_code=400, detail="hop_password_required") + hop_vendor = _normalize_hop_vendor(hop.hop_vendor) template = str(hop.hop_command_template or "").strip() - if not template: + if hop_vendor == "zte" and not template: template = default_zte_hop_template(hop.hop_protocol, hop.hop_vrf) ne_ids = [str(x).strip() for x in ids if str(x).strip()] @@ -305,7 +306,7 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d now = _now() for row in rows: row.hop_enabled = True - row.hop_vendor = _normalize_hop_vendor(hop.hop_vendor) + row.hop_vendor = hop_vendor row.hop_host = hop_host row.hop_port = int(hop.hop_port or 22) row.hop_protocol = _normalize_protocol(hop.hop_protocol) diff --git a/netx_api/ne_session_factory.py b/netx_api/ne_session_factory.py index b5872e5..8bb8a5f 100644 --- a/netx_api/ne_session_factory.py +++ b/netx_api/ne_session_factory.py @@ -1,4 +1,4 @@ -"""Netmiko session factory: direct connect or via ZTE jump host.""" +"""Netmiko session factory: direct connect, ZTE CLI hop, or Linux SSH bastion.""" from __future__ import annotations @@ -7,6 +7,7 @@ import re import time from typing import Any +import paramiko from netmiko import ConnectHandler from .config import settings @@ -182,8 +183,89 @@ 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() + hop_user = str(creds.get("hop_username") or "").strip() + hop_pass = str(creds.get("hop_password") or "") + if not hop_host or not hop_user or not hop_pass: + raise ValueError("hop_credentials_incomplete") + + timeout = int(settings.ne_connect_timeout_sec or 30) + hop_port = int(creds.get("hop_port") or 22) + target_ip = str(creds["ip_address"]) + target_port = int(creds.get("port") or 22) + + jump = paramiko.SSHClient() + jump.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + jump.connect( + hop_host, + port=hop_port, + username=hop_user, + password=hop_pass, + timeout=timeout, + banner_timeout=timeout, + auth_timeout=timeout, + look_for_keys=False, + allow_agent=False, + ) + transport = jump.get_transport() + if transport is None or not transport.is_active(): + raise ConnectionError("hop_connect_failed: jump transport inactive") + channel = transport.open_channel( + "direct-tcpip", + (target_ip, target_port), + ("127.0.0.1", 0), + timeout=timeout, + ) + except Exception: + try: + jump.close() + except Exception: + pass + raise + + device_type = normalize_netmiko_device_type(creds["device_type"], creds["protocol"]) + dev = _base_connect_kwargs( + device_type=device_type, + host=target_ip, + port=target_port, + username=str(creds["username"]), + password=str(creds["password"]), + enable_secret=str(creds.get("enable_secret") or ""), + session_timeout=session_timeout, + ) + dev["sock"] = channel + conn = ConnectHandler(**dev) + conn._netx_jump_client = jump # type: ignore[attr-defined] + return conn + + +def close_netmiko_connection(conn: ConnectHandler | None) -> None: + """Disconnect target session and any Linux bastion SSH client.""" + if conn is None: + return + jump = getattr(conn, "_netx_jump_client", None) + try: + conn.disconnect() + except Exception: + pass + if jump is not None: + try: + jump.close() + except Exception: + pass + + def open_netmiko_connection(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler: """Open a Netmiko connection to the target NE (direct or via configured hop).""" 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_direct(creds, session_timeout=session_timeout) diff --git a/web/src/components/HopProxyFields.tsx b/web/src/components/HopProxyFields.tsx index c07155c..5ac5884 100644 --- a/web/src/components/HopProxyFields.tsx +++ b/web/src/components/HopProxyFields.tsx @@ -1,8 +1,16 @@ import type { ReactNode } from "react"; import { useI18n } from "../i18n"; -import { isAutoHopTemplate, zteHopTemplate } from "../utils/zteHop"; +import { + HOP_VENDORS, + isAutoHopTemplate, + isLinuxHopVendor, + patchHopVendorChange, + zteHopTemplate, + type HopVendor, +} from "../utils/hopProxy"; export type HopProxyFieldsState = { + hop_vendor: HopVendor; hop_host: string; hop_port: number; hop_protocol: string; @@ -13,6 +21,7 @@ export type HopProxyFieldsState = { }; export const emptyHopProxyFields = (): HopProxyFieldsState => ({ + hop_vendor: "zte", hop_host: "", hop_port: 22, hop_protocol: "ssh", @@ -62,11 +71,31 @@ export function HopProxyFields({ hopPasswordOptional = false, }: Props) { const { t } = useI18n(); + const linux = isLinuxHopVendor(value.hop_vendor); const set = (patch: Partial) => onChange(patch); return (
+ - + {!linux ? ( + + ) : null} - - + {!linux ? ( + <> + + + + ) : null}
); } diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 76d2671..e2aa6c6 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -177,8 +177,15 @@ const en = { passwordOptional: "leave blank to keep unchanged", }, hop: { - sectionTitle: "ZTE jump host", + sectionTitle: "Jump host / proxy", + type: "Jump type", enable: "Connect to target via jump host", + vendor: { + zte: "ZTE device (CLI jump)", + linux: "Linux server (SSH tunnel)", + }, + zteHint: "Run ssh/telnet on the ZTE device to reach the target; target credentials use secondary auth.", + linuxHint: "SSH to the Linux bastion, then direct-tcpip tunnel to target IP:port (ProxyJump-style).", host: "Jump host", port: "Jump port", protocol: "Jump protocol", @@ -188,7 +195,10 @@ const en = { commandTemplate: "Jump command template", 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.", - badge: "hop", + badge: { + zte: "ZTE hop", + linux: "Linux hop", + }, hostRequired: "Jump host is required", userRequired: "Jump username is required", passwordRequired: "Jump password is required", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 4403c04..b718acf 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -176,8 +176,15 @@ const zh = { passwordOptional: "留空则不修改", }, hop: { - sectionTitle: "ZTE 跳板机", + sectionTitle: "跳板 / 代理", + type: "跳板类型", enable: "经跳板登录目标网元", + vendor: { + zte: "ZTE 设备(CLI 跳登)", + linux: "Linux 服务器(SSH 隧道)", + }, + zteHint: "在 ZTE 设备上执行 ssh/telnet 命令跳转到目标,目标账号由二次认证输入。", + linuxHint: "先 SSH 登录 Linux 跳板,经 direct-tcpip 隧道连接目标 IP:端口(等同 ProxyJump)。", host: "跳板地址", port: "跳板端口", protocol: "跳板协议", @@ -187,7 +194,10 @@ const zh = { commandTemplate: "跳登命令模板", templateHint: "ZTE 常用:telnet {target_ip}、telnet {target_ip} vrf {vrf}、ssh {target_ip}、ssh {target_ip} vrf {vrf}。按跳板协议与 VRF 自动推荐;留空时后端同样规则。目标账号密码由二次认证提示输入。", - badge: "跳板", + badge: { + zte: "ZTE跳板", + linux: "Linux跳板", + }, hostRequired: "请填写跳板地址", userRequired: "请填写跳板用户名", passwordRequired: "请填写跳板密码", diff --git a/web/src/pages/NePage.tsx b/web/src/pages/NePage.tsx index 7255259..0e379c8 100644 --- a/web/src/pages/NePage.tsx +++ b/web/src/pages/NePage.tsx @@ -18,7 +18,8 @@ import { useToast } from "../hooks/useToast"; import type { ManagedNeItem } from "../types"; import { pageCount } from "../utils/display"; import { formatSystemTime } from "../utils/time"; -import { isAutoHopTemplate, zteHopTemplate } from "../utils/zteHop"; +import { isAutoHopTemplate, patchHopVendorChange, zteHopTemplate } from "../utils/hopProxy"; +import type { HopVendor } from "../utils/hopProxy"; type FormState = { name: string; @@ -32,6 +33,7 @@ type FormState = { tags: string; remark: string; hop_enabled: boolean; + hop_vendor: HopVendor; hop_host: string; hop_port: number; hop_protocol: string; @@ -53,6 +55,7 @@ const emptyForm = (): FormState => ({ tags: "", remark: "", hop_enabled: false, + hop_vendor: "zte", hop_host: "", hop_port: 22, hop_protocol: "ssh", @@ -149,7 +152,7 @@ export function NePage() { tags: form.tags, remark: form.remark, hop_enabled: form.hop_enabled, - hop_vendor: "zte", + hop_vendor: form.hop_vendor, hop_host: form.hop_host, hop_port: form.hop_port, hop_protocol: form.hop_protocol, @@ -203,7 +206,7 @@ export function NePage() { const batchHopMutation = useMutation({ mutationFn: () => batchApplyHopManagedNe(selected, { - hop_vendor: "zte", + hop_vendor: batchHop.hop_vendor, hop_host: batchHop.hop_host.trim(), hop_port: batchHop.hop_port, hop_protocol: batchHop.hop_protocol, @@ -265,6 +268,7 @@ export function NePage() { tags: row.tags, remark: row.remark, hop_enabled: row.hop_enabled, + hop_vendor: (row.hop_vendor === "linux" ? "linux" : "zte") as HopVendor, hop_host: row.hop_host, hop_port: row.hop_port, hop_protocol: row.hop_protocol, @@ -432,8 +436,11 @@ export function NePage() { {row.ip_address}:{row.port}/{row.protocol} {row.hop_enabled ? ( - - {t("managedNe.hop.badge")} + + {t(`managedNe.hop.badge.${row.hop_vendor === "linux" ? "linux" : "zte"}`)} ) : null} @@ -597,7 +604,12 @@ export function NePage() { setForm((prev) => ({ ...prev, hop_enabled, - ...(hop_enabled ? applyHopTemplate(prev, prev.hop_protocol, prev.hop_vrf, true) : {}), + ...(hop_enabled + ? { + ...patchHopVendorChange(prev.hop_vendor, prev), + ...applyHopTemplate(prev, prev.hop_protocol, prev.hop_vrf, true), + } + : {}), })); }} /> @@ -606,6 +618,7 @@ export function NePage() { {form.hop_enabled ? (