Support bastion hop hosts as FQDN and pasted OpenSSH destinations.

Use placeholder examples (example.com / RFC5737) in docs and tests.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-11 17:32:03 +08:00
parent 35e7bb4667
commit b4514c13aa
12 changed files with 414 additions and 36 deletions

View file

@ -3,6 +3,7 @@ import { useI18n } from "../i18n";
import {
HOP_VENDORS,
defaultHopTemplate,
expandBastionHopFields,
isAutoHopTemplate,
isBastionHopVendor,
isLinuxHopVendor,
@ -127,7 +128,24 @@ export function HopProxyFields({
</label>
<label>
<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}
placeholder={bastion ? t("managedNe.hop.hostPlaceholderBastion") : undefined}
onChange={(e) => set({ hop_host: e.target.value })}
onBlur={() => {
if (!bastion) return;
const raw = String(value.hop_host || "").trim();
if (!raw.includes("@")) return;
const parsed = expandBastionHopFields(raw, value.hop_username);
if (!parsed.hop_host || parsed.hop_host === raw) return;
set({
hop_host: parsed.hop_host,
hop_username: parsed.hop_username || value.hop_username,
});
}}
/>
{bastion ? <span className="form-field-hint">{t("managedNe.hop.hostHintBastion")}</span> : null}
</label>
<label>
<FormLabel>{t("managedNe.hop.port")}</FormLabel>

View file

@ -820,9 +820,9 @@ const en = {
"· password may be empty (required for direct login; optional for bastion-managed or batch proxy later).\n" +
"· Recommended flow: import NEs first, select rows, then use Batch add proxy.\n\n" +
"[Jump / bastion]\n" +
"· Bastion SSH username template: {hop_user}@{target_user}@{target_ip} (bastion host is separate); CLI: ssh user@target@ip@bastion-host.\n" +
"· Bastion SSH username template: {hop_user}@{target_user}@{target_ip} (bastion host is separate IP or FQDN); CLI: ssh user@target@ip@bastion-host.\n" +
"· Bastion-managed: set Jump password (Vault); target password optional. Manual mode needs target password.\n" +
"· JumpServer/CBH often use port 2222; some sites use 22.\n\n" +
"· Jump host accepts IP (192.0.2.10) or domain (ssh-bastion.example.com). JumpServer/CBH often use port 2222; some sites use 22.\n\n" +
"[Connectivity / edit]\n" +
"· NETX_CREDENTIAL_SECRET_KEY required to store passwords.\n" +
"· Leave password blank on edit to keep unchanged; open Details after failed connect-test for hop context (no secrets).",
@ -859,15 +859,18 @@ const en = {
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).",
bastionHint:
"SSH with composite username via bastion protocol proxy. Example: bastion-user@target-user@2.2.2.2@1.1.1.1; password is the bastion/Vault password. JumpServer/CBH often use port 2222.",
"SSH with composite username via bastion protocol proxy. Hop host may be an IP or FQDN. Examples: bastion-user@target-user@198.51.100.20@192.0.2.10 or bastion-user@target-user@198.51.100.20@ssh-bastion.example.com; password is the bastion/Vault password. JumpServer/CBH often use port 2222.",
targetAuthMode: "Target credentials",
targetAuthBastionManaged: "Bastion-managed (target password optional)",
targetAuthManual: "Manual (secondary auth after connect)",
targetAuthHint: "Bastion-managed needs only bastion password; manual mode requires target NE password.",
usernameTemplate: "SSH username template",
templateHintBastion:
"Default {hop_user}@{target_user}@{target_ip}. Bastion address is the hop host field. CLI example: ssh bastion-user@target-user@2.2.2.2@1.1.1.1.",
"Default {hop_user}@{target_user}@{target_ip}. Bastion address (IP or FQDN) goes in Jump host. CLI example: ssh bastion-user@target-user@198.51.100.20@ssh-bastion.example.com.",
host: "Jump host",
hostPlaceholderBastion: "192.0.2.10 or ssh-bastion.example.com",
hostHintBastion:
"IP or FQDN. You can paste a full SSH destination (e.g. bastion-user@target-user@198.51.100.20@ssh-bastion.example.com); on blur it splits into jump username + host.",
port: "Jump port",
protocol: "Jump protocol",
protocolSshStelnet: "ssh (stelnet)",

View file

@ -852,15 +852,18 @@ const zh = {
ciscoHint: "在思科设备上执行 ssh -vrf / telnet /vrf 跳登,目标账号由二次认证输入。",
linuxHint: "先 SSH 登录 Linux 跳板,经 direct-tcpip 隧道连接目标 IP:端口(等同 ProxyJump)。",
bastionHint:
"SSH 复合用户名直连堡垒机,由堡垒机协议代理到目标。示例:bastion-user@target-user@2.2.2.2@1.1.1.1;密码为 Vault/堡垒机密码。JumpServer/CBH 常用端口 2222。",
"SSH 复合用户名直连堡垒机(跳板地址可为 IP 或域名),由堡垒机协议代理到目标。示例:bastion-user@target-user@198.51.100.20@192.0.2.10 或 bastion-user@target-user@198.51.100.20@ssh-bastion.example.com;密码为 Vault/堡垒机密码。JumpServer/CBH 常用端口 2222。",
targetAuthMode: "目标凭据",
targetAuthBastionManaged: "堡垒机托管(目标密码可留空)",
targetAuthManual: "手动输入(连接后二次认证)",
targetAuthHint: "堡垒机托管时仅需堡垒机密码;手动模式需填写目标网元密码。",
usernameTemplate: "SSH 用户名模板",
templateHintBastion:
"默认 {hop_user}@{target_user}@{target_ip},堡垒机地址填「跳板地址」。命令行示例:ssh bastion-user@target-user@2.2.2.2@1.1.1.1。",
"默认 {hop_user}@{target_user}@{target_ip},堡垒机地址(IP 或域名)填「跳板地址」。命令行示例:ssh bastion-user@target-user@198.51.100.20@ssh-bastion.example.com。",
host: "跳板地址",
hostPlaceholderBastion: "192.0.2.10 或 ssh-bastion.example.com",
hostHintBastion:
"支持 IP 或 FQDN。也可粘贴完整 SSH 目标串(如 bastion-user@target-user@198.51.100.20@ssh-bastion.example.com),失焦后自动拆成跳板用户名与地址。",
port: "跳板端口",
protocol: "跳板协议",
protocolSshStelnet: "ssh(stelnet)",

View file

@ -27,6 +27,88 @@ export function isCliHopVendor(vendor: string): boolean {
return v === "zte" || v === "huawei" || v === "cisco";
}
/** Strip ssh:// / trailing slash / :port from bastion host (IP or FQDN). */
export function normalizeHopHost(value: string): string {
let host = String(value || "").trim();
if (!host) return "";
host = host.replace(/^ssh(?:\s+-p\s+\d+)?\s+/i, "").trim();
if (host.includes("://")) host = host.split("://", 2)[1] || host;
host = host.trim().replace(/\/+$/, "");
if (host.startsWith("[") && host.includes("]")) {
const inside = host.slice(1, host.indexOf("]"));
const rest = host.slice(host.indexOf("]") + 1);
if (!rest || rest.startsWith(":")) return inside.trim();
return host;
}
if ((host.match(/:/g) || []).length === 1) {
const [left, right] = host.split(":");
if (/^\d+$/.test(right || "") && left && !left.includes("@")) return left.trim();
}
return host;
}
/**
* Parse OpenSSH-style bastion destination.
* Example: bastion-user@target-user@198.51.100.20@ssh-bastion.example.com
*/
export function parseBastionSshDestination(value: string): {
hop_host: string;
hop_username: string;
target_user: string;
target_ip: string;
ssh_username: string;
} {
let raw = String(value || "").trim().replace(/^["']|["']$/g, "");
raw = raw.replace(/^ssh(?:\s+-p\s+\d+)?\s+/i, "").trim();
if (!raw) {
return { hop_host: "", hop_username: "", target_user: "", target_ip: "", ssh_username: "" };
}
if (!raw.includes("@")) {
return {
hop_host: normalizeHopHost(raw),
hop_username: "",
target_user: "",
target_ip: "",
ssh_username: "",
};
}
const at = raw.lastIndexOf("@");
const userPart = raw.slice(0, at).trim();
const hopHost = normalizeHopHost(raw.slice(at + 1));
const parts = userPart.split("@").filter(Boolean);
return {
hop_host: hopHost,
hop_username: parts[0] || "",
target_user: parts[1] || "",
target_ip: parts[2] || "",
ssh_username: userPart,
};
}
/** If hop_host is a pasted user@…@bastion string, split into host + hop username. */
export function expandBastionHopFields(
hopHost: string,
hopUsername = "",
): {
hop_host: string;
hop_username: string;
target_user: string;
target_ip: string;
} {
const curUser = String(hopUsername || "").trim();
const raw = String(hopHost || "").trim();
if (!raw.includes("@")) {
return { hop_host: normalizeHopHost(raw), hop_username: curUser, target_user: "", target_ip: "" };
}
const parsed = parseBastionSshDestination(raw);
return {
hop_host: parsed.hop_host,
hop_username: parsed.hop_username || curUser,
target_user: parsed.target_user,
target_ip: parsed.target_ip,
};
}
export function defaultHopTemplate(vendor: string, protocol: string, vrf: string): string {
const v = String(vendor || "zte").toLowerCase();
if (v === "huawei") return huaweiHopTemplate(protocol, vrf);