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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-28 17:38:37 +08:00
parent d395aa7174
commit d3eae3351c
9 changed files with 232 additions and 59 deletions

View file

@ -16,7 +16,7 @@ from .models import ManagedNE, NeCollectionJob, NeCollectionRun
from .ne_collection_paths import clear_run_output_files, run_output_dir from .ne_collection_paths import clear_run_output_files, run_output_dir
from .ne_crypto import CredentialCryptoError from .ne_crypto import CredentialCryptoError
from .ne_service import get_device_credentials 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") _log = logging.getLogger("netx.ne.collect")
_executor: ThreadPoolExecutor | None = None _executor: ThreadPoolExecutor | None = None
@ -57,10 +57,7 @@ def _collect_on_device(creds: dict[str, Any], commands: list[str]) -> str:
chunks.append("\n") chunks.append("\n")
return "".join(chunks) return "".join(chunks)
finally: finally:
try: close_netmiko_connection(conn)
conn.disconnect()
except Exception:
pass
def _collect_with_timeout(creds: dict[str, Any], commands: list[str]) -> str: def _collect_with_timeout(creds: dict[str, Any], commands: list[str]) -> str:

View file

@ -11,7 +11,7 @@ from .db import SessionLocal
from .models import ManagedNE from .models import ManagedNE
from .ne_crypto import CredentialCryptoError from .ne_crypto import CredentialCryptoError
from .ne_service import get_device_credentials 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") _log = logging.getLogger("netx.ne.connect")
_executor: ThreadPoolExecutor | None = None _executor: ThreadPoolExecutor | None = None
@ -104,6 +104,7 @@ 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] detail = str(exc).split("\n")[0][:480]
if creds.get("hop_enabled"): 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: if "hop_credentials_incomplete" in raw or "hop_command_template_invalid" in raw:
return detail return detail
if "target_auth_timeout" in raw: 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: if "hop_host" in raw or str(creds.get("hop_host") or "") in raw:
return "hop_auth_failed: " + detail return "hop_auth_failed: " + detail
return "target_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_connect_failed: " + detail
return "hop_command_failed: " + detail return "hop_command_failed: " + detail
return detail return detail
@ -142,11 +145,7 @@ def _probe_device(creds: dict[str, Any]) -> tuple[str, str, str | None]:
except Exception as exc: except Exception as exc:
return "fail", _classify_connect_error(creds, exc), None return "fail", _classify_connect_error(creds, exc), None
finally: finally:
if conn is not None: close_netmiko_connection(conn)
try:
conn.disconnect()
except Exception:
pass
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) -> None:

View file

@ -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",) else "zte" return v if v in ("zte", "linux") else "zte"
def _validate_hop_on_create(body: ManagedNeCreate) -> None: 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: if not hop_pass:
raise HTTPException(status_code=400, detail="hop_password_required") 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() 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) template = default_zte_hop_template(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()]
@ -305,7 +306,7 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d
now = _now() now = _now()
for row in rows: for row in rows:
row.hop_enabled = True 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_host = hop_host
row.hop_port = int(hop.hop_port or 22) row.hop_port = int(hop.hop_port or 22)
row.hop_protocol = _normalize_protocol(hop.hop_protocol) row.hop_protocol = _normalize_protocol(hop.hop_protocol)

View file

@ -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 from __future__ import annotations
@ -7,6 +7,7 @@ import re
import time import time
from typing import Any from typing import Any
import paramiko
from netmiko import ConnectHandler from netmiko import ConnectHandler
from .config import settings from .config import settings
@ -182,8 +183,89 @@ 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:
"""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: 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).""" """Open a Netmiko connection to the target NE (direct or via configured hop)."""
if creds.get("hop_enabled"): 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_zte_hop(creds, session_timeout=session_timeout)
return _connect_direct(creds, session_timeout=session_timeout) return _connect_direct(creds, session_timeout=session_timeout)

View file

@ -1,8 +1,16 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useI18n } from "../i18n"; 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 = { export type HopProxyFieldsState = {
hop_vendor: HopVendor;
hop_host: string; hop_host: string;
hop_port: number; hop_port: number;
hop_protocol: string; hop_protocol: string;
@ -13,6 +21,7 @@ export type HopProxyFieldsState = {
}; };
export const emptyHopProxyFields = (): HopProxyFieldsState => ({ export const emptyHopProxyFields = (): HopProxyFieldsState => ({
hop_vendor: "zte",
hop_host: "", hop_host: "",
hop_port: 22, hop_port: 22,
hop_protocol: "ssh", hop_protocol: "ssh",
@ -62,11 +71,31 @@ export function HopProxyFields({
hopPasswordOptional = false, hopPasswordOptional = false,
}: Props) { }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const linux = isLinuxHopVendor(value.hop_vendor);
const set = (patch: Partial<HopProxyFieldsState>) => onChange(patch); const set = (patch: Partial<HopProxyFieldsState>) => onChange(patch);
return ( return (
<div className="form-grid"> <div className="form-grid">
<label className="form-grid__full">
<FormLabel required>{t("managedNe.hop.type")}</FormLabel>
<select
value={value.hop_vendor}
onChange={(e) => {
const hop_vendor = e.target.value as HopVendor;
set(patchHopVendorChange(hop_vendor, value));
}}
>
{HOP_VENDORS.map((v) => (
<option key={v} value={v}>
{t(`managedNe.hop.vendor.${v}`)}
</option>
))}
</select>
<span className="form-field-hint">
{linux ? t("managedNe.hop.linuxHint") : t("managedNe.hop.zteHint")}
</span>
</label>
<label> <label>
<FormLabel required>{t("managedNe.hop.host")}</FormLabel> <FormLabel required>{t("managedNe.hop.host")}</FormLabel>
<input required value={value.hop_host} onChange={(e) => set({ hop_host: e.target.value })} /> <input required value={value.hop_host} onChange={(e) => set({ hop_host: e.target.value })} />
@ -79,19 +108,21 @@ export function HopProxyFields({
onChange={(e) => set({ hop_port: Number(e.target.value) || 22 })} onChange={(e) => set({ hop_port: Number(e.target.value) || 22 })}
/> />
</label> </label>
<label> {!linux ? (
<FormLabel>{t("managedNe.hop.protocol")}</FormLabel> <label>
<select <FormLabel>{t("managedNe.hop.protocol")}</FormLabel>
value={value.hop_protocol} <select
onChange={(e) => { value={value.hop_protocol}
const hop_protocol = e.target.value; onChange={(e) => {
set({ hop_protocol, ...applyHopTemplate(value, hop_protocol, value.hop_vrf) }); const hop_protocol = e.target.value;
}} set({ hop_protocol, ...applyHopTemplate(value, hop_protocol, value.hop_vrf) });
> }}
<option value="ssh">ssh</option> >
<option value="telnet">telnet</option> <option value="ssh">ssh</option>
</select> <option value="telnet">telnet</option>
</label> </select>
</label>
) : null}
<label> <label>
<FormLabel required>{t("managedNe.hop.username")}</FormLabel> <FormLabel required>{t("managedNe.hop.username")}</FormLabel>
<input required value={value.hop_username} onChange={(e) => set({ hop_username: e.target.value })} /> <input required value={value.hop_username} onChange={(e) => set({ hop_username: e.target.value })} />
@ -110,25 +141,29 @@ export function HopProxyFields({
onChange={(e) => set({ hop_password: e.target.value })} onChange={(e) => set({ hop_password: e.target.value })}
/> />
</label> </label>
<label> {!linux ? (
<FormLabel>{t("managedNe.hop.vrf")}</FormLabel> <>
<input <label>
value={value.hop_vrf} <FormLabel>{t("managedNe.hop.vrf")}</FormLabel>
onChange={(e) => { <input
const hop_vrf = e.target.value; value={value.hop_vrf}
set({ hop_vrf, ...applyHopTemplate(value, value.hop_protocol, hop_vrf) }); onChange={(e) => {
}} const hop_vrf = e.target.value;
/> set({ hop_vrf, ...applyHopTemplate(value, value.hop_protocol, hop_vrf) });
</label> }}
<label className="form-grid__full"> />
<FormLabel>{t("managedNe.hop.commandTemplate")}</FormLabel> </label>
<input <label className="form-grid__full">
value={value.hop_command_template} <FormLabel>{t("managedNe.hop.commandTemplate")}</FormLabel>
onChange={(e) => set({ hop_command_template: e.target.value })} <input
placeholder={zteHopTemplate(value.hop_protocol, value.hop_vrf)} value={value.hop_command_template}
/> onChange={(e) => set({ hop_command_template: e.target.value })}
<span className="form-field-hint">{t("managedNe.hop.templateHint")}</span> placeholder={zteHopTemplate(value.hop_protocol, value.hop_vrf)}
</label> />
<span className="form-field-hint">{t("managedNe.hop.templateHint")}</span>
</label>
</>
) : null}
</div> </div>
); );
} }

View file

@ -177,8 +177,15 @@ const en = {
passwordOptional: "leave blank to keep unchanged", passwordOptional: "leave blank to keep unchanged",
}, },
hop: { hop: {
sectionTitle: "ZTE jump host", sectionTitle: "Jump host / proxy",
type: "Jump type",
enable: "Connect to target via jump host", 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", host: "Jump host",
port: "Jump port", port: "Jump port",
protocol: "Jump protocol", protocol: "Jump protocol",
@ -188,7 +195,10 @@ const en = {
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.",
badge: "hop", badge: {
zte: "ZTE hop",
linux: "Linux hop",
},
hostRequired: "Jump host is required", hostRequired: "Jump host is required",
userRequired: "Jump username is required", userRequired: "Jump username is required",
passwordRequired: "Jump password is required", passwordRequired: "Jump password is required",

View file

@ -176,8 +176,15 @@ const zh = {
passwordOptional: "留空则不修改", passwordOptional: "留空则不修改",
}, },
hop: { hop: {
sectionTitle: "ZTE 跳板机", sectionTitle: "跳板 / 代理",
type: "跳板类型",
enable: "经跳板登录目标网元", enable: "经跳板登录目标网元",
vendor: {
zte: "ZTE 设备(CLI 跳登)",
linux: "Linux 服务器(SSH 隧道)",
},
zteHint: "在 ZTE 设备上执行 ssh/telnet 命令跳转到目标,目标账号由二次认证输入。",
linuxHint: "先 SSH 登录 Linux 跳板,经 direct-tcpip 隧道连接目标 IP:端口(等同 ProxyJump)。",
host: "跳板地址", host: "跳板地址",
port: "跳板端口", port: "跳板端口",
protocol: "跳板协议", protocol: "跳板协议",
@ -187,7 +194,10 @@ const zh = {
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 自动推荐;留空时后端同样规则。目标账号密码由二次认证提示输入。",
badge: "跳板", badge: {
zte: "ZTE跳板",
linux: "Linux跳板",
},
hostRequired: "请填写跳板地址", hostRequired: "请填写跳板地址",
userRequired: "请填写跳板用户名", userRequired: "请填写跳板用户名",
passwordRequired: "请填写跳板密码", passwordRequired: "请填写跳板密码",

View file

@ -18,7 +18,8 @@ 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, zteHopTemplate } from "../utils/zteHop"; import { isAutoHopTemplate, patchHopVendorChange, zteHopTemplate } from "../utils/hopProxy";
import type { HopVendor } from "../utils/hopProxy";
type FormState = { type FormState = {
name: string; name: string;
@ -32,6 +33,7 @@ type FormState = {
tags: string; tags: string;
remark: string; remark: string;
hop_enabled: boolean; hop_enabled: boolean;
hop_vendor: HopVendor;
hop_host: string; hop_host: string;
hop_port: number; hop_port: number;
hop_protocol: string; hop_protocol: string;
@ -53,6 +55,7 @@ const emptyForm = (): FormState => ({
tags: "", tags: "",
remark: "", remark: "",
hop_enabled: false, hop_enabled: false,
hop_vendor: "zte",
hop_host: "", hop_host: "",
hop_port: 22, hop_port: 22,
hop_protocol: "ssh", hop_protocol: "ssh",
@ -149,7 +152,7 @@ export function NePage() {
tags: form.tags, tags: form.tags,
remark: form.remark, remark: form.remark,
hop_enabled: form.hop_enabled, hop_enabled: form.hop_enabled,
hop_vendor: "zte", hop_vendor: form.hop_vendor,
hop_host: form.hop_host, hop_host: form.hop_host,
hop_port: form.hop_port, hop_port: form.hop_port,
hop_protocol: form.hop_protocol, hop_protocol: form.hop_protocol,
@ -203,7 +206,7 @@ export function NePage() {
const batchHopMutation = useMutation({ const batchHopMutation = useMutation({
mutationFn: () => mutationFn: () =>
batchApplyHopManagedNe(selected, { batchApplyHopManagedNe(selected, {
hop_vendor: "zte", hop_vendor: batchHop.hop_vendor,
hop_host: batchHop.hop_host.trim(), hop_host: batchHop.hop_host.trim(),
hop_port: batchHop.hop_port, hop_port: batchHop.hop_port,
hop_protocol: batchHop.hop_protocol, hop_protocol: batchHop.hop_protocol,
@ -265,6 +268,7 @@ 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_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,
@ -432,8 +436,11 @@ export function NePage() {
<td> <td>
{row.ip_address}:{row.port}/{row.protocol} {row.ip_address}:{row.port}/{row.protocol}
{row.hop_enabled ? ( {row.hop_enabled ? (
<span className="table-tag" title={`${row.hop_host}:${row.hop_port}`}> <span
{t("managedNe.hop.badge")} className="table-tag"
title={`${row.hop_host}:${row.hop_port} (${row.hop_vendor})`}
>
{t(`managedNe.hop.badge.${row.hop_vendor === "linux" ? "linux" : "zte"}`)}
</span> </span>
) : null} ) : null}
</td> </td>
@ -597,7 +604,12 @@ export function NePage() {
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
hop_enabled, 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 ? ( {form.hop_enabled ? (
<HopProxyFields <HopProxyFields
value={{ value={{
hop_vendor: form.hop_vendor,
hop_host: form.hop_host, hop_host: form.hop_host,
hop_port: form.hop_port, hop_port: form.hop_port,
hop_protocol: form.hop_protocol, hop_protocol: form.hop_protocol,

26
web/src/utils/hopProxy.ts Normal file
View file

@ -0,0 +1,26 @@
import { isAutoHopTemplate, zteHopTemplate } from "./zteHop";
export type HopVendor = "zte" | "linux";
export const HOP_VENDORS: HopVendor[] = ["zte", "linux"];
export function isLinuxHopVendor(vendor: string): boolean {
return String(vendor || "").toLowerCase() === "linux";
}
export function patchHopVendorChange(
vendor: HopVendor,
prev: { 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") {
return { hop_vendor: "linux", hop_protocol: "ssh", hop_vrf: "", hop_command_template: "" };
}
return {
hop_vendor: "zte",
hop_protocol: prev.hop_protocol || "ssh",
hop_vrf: prev.hop_vrf,
hop_command_template: zteHopTemplate(prev.hop_protocol || "ssh", prev.hop_vrf),
};
}
export { isAutoHopTemplate, zteHopTemplate };