diff --git a/netx_api/ne_connect.py b/netx_api/ne_connect.py index 1d87a4e..78684ea 100644 --- a/netx_api/ne_connect.py +++ b/netx_api/ne_connect.py @@ -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 diff --git a/netx_api/ne_session_factory.py b/netx_api/ne_session_factory.py index 65d8463..f438916 100644 --- a/netx_api/ne_session_factory.py +++ b/netx_api/ne_session_factory.py @@ -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, diff --git a/tests/test_bastion_hop.py b/tests/test_bastion_hop.py index 6ed1c82..8470435 100644 --- a/tests/test_bastion_hop.py +++ b/tests/test_bastion_hop.py @@ -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() diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 089f83a..9bc9ffb 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index b50df89..0c11050 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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: "跳板协议", diff --git a/web/src/utils/hopProxy.ts b/web/src/utils/hopProxy.ts index 12042d1..d28e3ff 100644 --- a/web/src/utils/hopProxy.ts +++ b/web/src/utils/hopProxy.ts @@ -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"; } diff --git a/web/src/utils/zteHop.ts b/web/src/utils/zteHop.ts index 0356d63..75eb0a1 100644 --- a/web/src/utils/zteHop.ts +++ b/web/src/utils/zteHop.ts @@ -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); }