fix(bastion): match OpenSSH username parsing for protocol-proxy hop

OpenSSH treats the last @ as user/host separator, so the SSH username must be hop@target@ip without duplicating hop_host. Legacy templates with {hop_host} are stripped automatically; connect logs now show bastion_ssh_cli for comparison with manual ssh.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-23 21:49:43 +08:00
parent 79ae5ff31c
commit 088e920f9d
7 changed files with 197 additions and 67 deletions

View file

@ -12,7 +12,13 @@ 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 close_netmiko_connection, open_netmiko_connection
from .ne_session_factory import (
bastion_ssh_cli,
close_netmiko_connection,
open_netmiko_connection,
render_hop_command,
resolve_bastion_ssh_username,
)
_log = logging.getLogger("netx.ne.connect")
_executor: ThreadPoolExecutor | None = None
@ -50,6 +56,17 @@ def _connect_context_lines(creds: dict[str, Any]) -> list[str]:
auth_mode = str(creds.get("hop_target_auth_mode") or "").strip()
if auth_mode:
lines.append(f"hop_target_auth_mode={auth_mode}")
if str(creds.get("hop_vendor") or "").strip().lower() == "bastion":
try:
hop_host = str(creds.get("hop_host") or "").strip()
rendered = render_hop_command(str(creds.get("hop_command_template") or ""), creds)
ssh_user = resolve_bastion_ssh_username(rendered, hop_host)
lines.append(f"bastion_ssh_username={ssh_user}")
lines.append(
f"bastion_ssh_cli={bastion_ssh_cli(ssh_user, hop_host, int(creds.get('hop_port') or 22))}"
)
except Exception:
pass
vrf = str(creds.get("hop_vrf") or "").strip()
if vrf:
lines.append(f"hop_vrf={vrf}")
@ -147,7 +164,16 @@ def _classify_connect_error(creds: dict[str, Any], exc: BaseException) -> str:
return detail
if "target_auth_timeout" in raw:
return "target_auth_failed: " + detail
if hop_v == "bastion" and (
"bastion_vault_auth_failed" in raw
or "bad authentication type" in raw
or "keyboard-interactive" in raw
or "vault" in raw
):
return "bastion_auth_failed: " + detail
if "authentication" in raw or "auth" in raw:
if hop_v == "bastion":
return "bastion_auth_failed: " + detail
if "hop_host" in raw or str(creds.get("hop_host") or "") in raw:
return "hop_auth_failed: " + detail
return "target_auth_failed: " + detail

View file

@ -54,8 +54,39 @@ def default_huawei_hop_template(protocol: str, vrf: str = "") -> str:
def default_bastion_username_template() -> str:
"""SSH bastion composite username (JumpServer/CBH/generic protocol-proxy style)."""
return "{hop_user}@{target_user}@{target_ip}@{hop_host}"
"""SSH username sent to bastion (OpenSSH splits user@host at the last @)."""
return "{hop_user}@{target_user}@{target_ip}"
_LEGACY_BASTION_USERNAME_TEMPLATE = "{hop_user}@{target_user}@{target_ip}@{hop_host}"
def resolve_bastion_ssh_username(rendered: str, hop_host: str) -> str:
"""Map template output to the SSH username Paramiko must send.
CLI ``ssh hop@target@ip@bastion`` is parsed by OpenSSH as user ``hop@target@ip``
and host ``bastion``. Legacy templates that included ``{hop_host}`` duplicated the
bastion address inside the username and break authentication.
"""
user = str(rendered or "").strip()
host = str(hop_host or "").strip()
if not user or not host:
return user
suffix = f"@{host}"
if user.endswith(suffix):
return user[:-len(suffix)]
return user
def bastion_ssh_cli(username: str, hop_host: str, hop_port: int = 22) -> str:
"""Human-readable ssh command equivalent (for logs/UI)."""
host = str(hop_host or "").strip()
user = str(username or "").strip()
target = f"{user}@{host}" if user else host
port = int(hop_port or 22)
if port != 22:
return f"ssh -p {port} {target}"
return f"ssh {target}"
def default_hop_command_template(vendor: str, protocol: str, vrf: str = "") -> str:
@ -99,24 +130,37 @@ def render_hop_command(template: str, creds: dict[str, Any]) -> str:
return out
def _bastion_interactive_handler(password: str):
def _bastion_interactive_handler(password: str) -> tuple[Any, list[str]]:
"""Reply to bastion keyboard-interactive prompts (Vault password, OTP, etc.)."""
seen_prompts: list[str] = []
def handler(title: str, instructions: str, prompt_list: list[tuple[str, bool]]) -> list[str]:
for prompt, _echo in prompt_list:
seen_prompts.append(str(prompt or ""))
if not prompt_list:
return []
responses: list[str] = []
for prompt, _echo in prompt_list:
pl = str(prompt or "").lower()
if re.search(r"password|vault|口令|密码|passcode|otp|token|verification|verify", pl):
responses.append(password)
else:
responses.append("")
if not any(responses):
responses = [password] * len(prompt_list)
return responses
return [password] * len(prompt_list)
return handler
return handler, seen_prompts
def _bastion_auth_error_message(*, username: str, prompts: list[str], exc: Exception) -> str:
parts = [
"bastion_vault_auth_failed: verify hop_password (Vault password)",
f"bastion_ssh_username={username!r}",
]
if prompts:
parts.append(f"prompts={prompts!r}")
parts.append(f"detail={exc}")
return "; ".join(parts)
def _bastion_start_transport(*, host: str, port: int, timeout: int) -> paramiko.Transport:
transport = paramiko.Transport((host, int(port or 22)))
transport.banner_timeout = timeout
transport.auth_timeout = timeout
transport.start_client(timeout=timeout)
return transport
def _bastion_ssh_connect(
@ -127,47 +171,64 @@ def _bastion_ssh_connect(
password: str,
timeout: int,
) -> paramiko.SSHClient:
"""SSH to protocol-proxy bastion; interactive auth first (JumpServer/CBH/ZTE-TSM)."""
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
transport = paramiko.Transport((host, int(port or 22)))
transport.banner_timeout = timeout
transport.auth_timeout = timeout
try:
transport.start_client(timeout=timeout)
except Exception:
try:
transport.close()
except Exception:
pass
raise
"""SSH to protocol-proxy bastion (JumpServer/CBH/ZTE-TSM).
handler = _bastion_interactive_handler(password)
auth_errors: list[Exception] = []
for use_interactive in (True, False):
Each strategy uses a fresh transport. Prefer password→keyboard-interactive
fallback (OpenSSH-style) before a direct interactive attempt.
"""
handler, prompt_trace = _bastion_interactive_handler(password)
strategies: list[tuple[str, Any]] = [
(
"password_kb_fallback",
lambda transport: transport.auth_password(username, password, fallback=True),
),
(
"interactive",
lambda transport: transport.auth_interactive(username, handler),
),
]
auth_errors: list[tuple[str, Exception]] = []
for name, authenticate in strategies:
transport: paramiko.Transport | None = None
try:
if use_interactive:
transport.auth_interactive(username, handler)
else:
transport.auth_password(username, password, fallback=False)
transport = _bastion_start_transport(host=host, port=port, timeout=timeout)
authenticate(transport)
if transport.is_authenticated():
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client._transport = transport # noqa: SLF001
return client
except paramiko.BadAuthenticationType as exc:
auth_errors.append(exc)
if use_interactive and "keyboard-interactive" not in getattr(exc, "allowed_types", ()):
continue
except paramiko.AuthenticationException as exc:
auth_errors.append(exc)
continue
except Exception as exc:
auth_errors.append((name, exc))
finally:
if transport is not None and not transport.is_authenticated():
try:
transport.close()
except Exception:
pass
try:
transport.close()
except Exception:
pass
if auth_errors:
raise auth_errors[-1]
raise paramiko.AuthenticationException("bastion_auth_failed")
preferred = next(
(
(name, exc)
for name, exc in auth_errors
if isinstance(exc, paramiko.AuthenticationException)
and not isinstance(exc, paramiko.BadAuthenticationType)
),
auth_errors[-1] if auth_errors else None,
)
if preferred is not None:
_name, exc = preferred
raise paramiko.AuthenticationException(
_bastion_auth_error_message(username=username, prompts=prompt_trace, exc=exc)
) from exc
raise paramiko.AuthenticationException(
_bastion_auth_error_message(
username=username,
prompts=prompt_trace,
exc=Exception("bastion_auth_failed"),
)
)
def _netmiko_over_ssh_client(
@ -360,7 +421,8 @@ def _connect_via_bastion(creds: dict[str, Any], *, session_timeout: int | None =
if not hop_host or not hop_user or not hop_pass:
raise ValueError("hop_credentials_incomplete")
composite_user = render_hop_command(str(creds.get("hop_command_template") or ""), creds)
composite_rendered = render_hop_command(str(creds.get("hop_command_template") or ""), creds)
ssh_username = resolve_bastion_ssh_username(composite_rendered, hop_host)
device_type = normalize_netmiko_device_type(creds["device_type"], creds["protocol"])
hop_port = int(creds.get("hop_port") or 22)
timeout = int(settings.ne_connect_timeout_sec or 30)
@ -369,7 +431,7 @@ def _connect_via_bastion(creds: dict[str, Any], *, session_timeout: int | None =
ssh_client = _bastion_ssh_connect(
host=hop_host,
port=hop_port,
username=composite_user,
username=ssh_username,
password=hop_pass,
timeout=timeout,
)
@ -378,7 +440,7 @@ def _connect_via_bastion(creds: dict[str, Any], *, session_timeout: int | None =
device_type=device_type,
host=hop_host,
port=hop_port,
username=composite_user,
username=ssh_username,
password=hop_pass,
enable_secret=str(creds.get("enable_secret") or ""),
session_timeout=session_timeout or 180,

View file

@ -4,9 +4,11 @@ import unittest
from unittest.mock import MagicMock, patch
from netx_api.ne_session_factory import (
bastion_ssh_cli,
default_bastion_username_template,
open_netmiko_connection,
render_hop_command,
resolve_bastion_ssh_username,
)
@ -14,7 +16,7 @@ class BastionTemplateTests(unittest.TestCase):
def test_default_bastion_username_template(self) -> None:
self.assertEqual(
default_bastion_username_template(),
"{hop_user}@{target_user}@{target_ip}@{hop_host}",
"{hop_user}@{target_user}@{target_ip}",
)
def test_render_bastion_composite_username(self) -> None:
@ -28,7 +30,7 @@ class BastionTemplateTests(unittest.TestCase):
"hop_vrf": "",
}
out = render_hop_command("", creds)
self.assertEqual(out, "bastion-user@target-user@2.2.2.2@1.1.1.1")
self.assertEqual(out, "bastion-user@target-user@2.2.2.2")
def test_render_custom_bastion_template(self) -> None:
creds = {
@ -44,6 +46,24 @@ class BastionTemplateTests(unittest.TestCase):
out = render_hop_command(creds["hop_command_template"], creds)
self.assertEqual(out, "admin#root@5.6.7.8")
def test_resolve_strips_legacy_hop_host_suffix(self) -> None:
self.assertEqual(
resolve_bastion_ssh_username("ZTE-FIVIE@ca-admin@114.1.198.1@10.34.145.25", "10.34.145.25"),
"ZTE-FIVIE@ca-admin@114.1.198.1",
)
def test_resolve_keeps_username_without_hop_host_suffix(self) -> None:
self.assertEqual(
resolve_bastion_ssh_username("ZTE-FIVIE@ca-admin@114.1.198.1", "10.34.145.25"),
"ZTE-FIVIE@ca-admin@114.1.198.1",
)
def test_bastion_ssh_cli(self) -> None:
self.assertEqual(
bastion_ssh_cli("ZTE-FIVIE@ca-admin@114.1.198.1", "10.34.145.25"),
"ssh ZTE-FIVIE@ca-admin@114.1.198.1@10.34.145.25",
)
class BastionConnectRoutingTests(unittest.TestCase):
@patch("netx_api.ne_session_factory._connect_via_bastion")
@ -94,14 +114,14 @@ class BastionConnectImplTests(unittest.TestCase):
bastion_ssh.assert_called_once_with(
host="1.1.1.1",
port=22,
username="bastion-user@target-user@2.2.2.2@1.1.1.1",
username="bastion-user@target-user@2.2.2.2",
password="vault-pass",
timeout=unittest.mock.ANY,
)
netmiko_wrap.assert_called_once()
wrap_kwargs = netmiko_wrap.call_args.kwargs
self.assertEqual(wrap_kwargs["host"], "1.1.1.1")
self.assertEqual(wrap_kwargs["username"], "bastion-user@target-user@2.2.2.2@1.1.1.1")
self.assertEqual(wrap_kwargs["username"], "bastion-user@target-user@2.2.2.2")
self.assertEqual(wrap_kwargs["password"], "vault-pass")
conn.disconnect.assert_not_called()
@ -131,8 +151,16 @@ class BastionConnectImplTests(unittest.TestCase):
"hop_vendor": "bastion",
"hop_protocol": "ssh",
"hop_vrf": "",
"hop_command_template": "{hop_user}@{target_user}@{target_ip}@{hop_host}",
}
_connect_via_bastion(creds)
bastion_ssh.assert_called_once_with(
host="10.0.0.1",
port=2222,
username="bastion-user@target-user@10.0.0.2",
password="bastion-pass",
timeout=unittest.mock.ANY,
)
interact.assert_called_once_with(conn, "target-user", "target-pass")
@ -140,7 +168,7 @@ class BastionInteractiveHandlerTests(unittest.TestCase):
def test_replies_to_vault_password_prompt(self) -> None:
from netx_api.ne_session_factory import _bastion_interactive_handler
handler = _bastion_interactive_handler("vault-secret")
handler, _prompts = _bastion_interactive_handler("vault-secret")
out = handler(
"Login",
"",
@ -148,19 +176,26 @@ class BastionInteractiveHandlerTests(unittest.TestCase):
)
self.assertEqual(out, ["vault-secret"])
def test_replies_to_all_fields_when_prompt_unknown(self) -> None:
def test_replies_to_all_fields(self) -> None:
from netx_api.ne_session_factory import _bastion_interactive_handler
handler = _bastion_interactive_handler("vault-secret")
handler, _prompts = _bastion_interactive_handler("vault-secret")
out = handler("Login", "", [("Enter code:", False), ("Confirm:", False)])
self.assertEqual(out, ["vault-secret", "vault-secret"])
def test_empty_prompt_list_returns_empty(self) -> None:
from netx_api.ne_session_factory import _bastion_interactive_handler
handler = _bastion_interactive_handler("vault-secret")
handler, _prompts = _bastion_interactive_handler("vault-secret")
self.assertEqual(handler("Login", "", []), [])
def test_records_prompts_for_diagnostics(self) -> None:
from netx_api.ne_session_factory import _bastion_interactive_handler
handler, prompts = _bastion_interactive_handler("vault-secret")
handler("Login", "", [("Vault Password:", False)])
self.assertEqual(prompts, ["Vault Password:"])
if __name__ == "__main__":
unittest.main()

View file

@ -194,7 +194,7 @@ 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}@{hop_host}; target account = NE Username.\n" +
"· Bastion SSH username template: {hop_user}@{target_user}@{target_ip} (bastion host is separate); 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" +
"[Connectivity / edit]\n" +
@ -240,7 +240,7 @@ const en = {
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}@{hop_host}. Same when left blank. Example: bastion-user@target-user@2.2.2.2@1.1.1.1.",
"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.",
host: "Jump host",
port: "Jump port",
protocol: "Jump protocol",

View file

@ -192,7 +192,7 @@ const zh = {
"· password 可留空(直连需填;堡垒机托管或后续批量添加代理时可空)。\n" +
"· 推荐流程:先导入网元 → 勾选网元 → 点「批量添加代理」统一配置跳板/堡垒机。\n\n" +
"【跳板 / 堡垒机】\n" +
"· 堡垒机 SSH 用户名模板:{hop_user}@{target_user}@{target_ip}@{hop_host};目标账号填网元「用户名」。\n" +
"· 堡垒机 SSH 用户名模板:{hop_user}@{target_user}@{target_ip}(堡垒机地址单独填在跳板地址);命令行等价:ssh 用户@目标@IP@堡垒机。\n" +
"· 堡垒机托管时填「跳板密码」(Vault 密码),目标密码可留空;手动模式需填目标密码。\n" +
"· JumpServer/CBH 常用跳板端口 2222,部分现场为 22。\n\n" +
"【连通性 / 编辑】\n" +
@ -238,7 +238,7 @@ const zh = {
targetAuthHint: "堡垒机托管时仅需堡垒机密码;手动模式需填写目标网元密码。",
usernameTemplate: "SSH 用户名模板",
templateHintBastion:
"默认 {hop_user}@{target_user}@{target_ip}@{hop_host}。留空时后端同样规则。示例:bastion-user@target-user@2.2.2.2@1.1.1.1。",
"默认 {hop_user}@{target_user}@{target_ip},堡垒机地址填「跳板地址」。命令行示例:ssh bastion-user@target-user@2.2.2.2@1.1.1.1。",
host: "跳板地址",
port: "跳板端口",
protocol: "跳板协议",

View file

@ -9,9 +9,11 @@ export type HopTargetAuthMode = "bastion_managed" | "manual";
export const HOP_VENDORS: HopVendor[] = ["zte", "huawei", "cisco", "linux", "bastion"];
export function bastionHopTemplate(): string {
return "{hop_user}@{target_user}@{target_ip}@{hop_host}";
return "{hop_user}@{target_user}@{target_ip}";
}
export const LEGACY_BASTION_HOP_TEMPLATE = "{hop_user}@{target_user}@{target_ip}@{hop_host}";
export function isBastionHopVendor(vendor: string): boolean {
return String(vendor || "").toLowerCase() === "bastion";
}

View file

@ -42,6 +42,11 @@ export function isAutoHopTemplate(
if (v === "huawei") return t === huaweiHopTemplate(protocol, vrf);
if (v === "cisco") return t === ciscoHopTemplate(protocol, vrf);
if (v === "linux") return t === "";
if (v === "bastion") return t === "{hop_user}@{target_user}@{target_ip}@{hop_host}";
if (v === "bastion") {
return (
t === "{hop_user}@{target_user}@{target_ip}"
|| t === "{hop_user}@{target_user}@{target_ip}@{hop_host}"
);
}
return t === zteHopTemplate(protocol, vrf);
}