mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
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:
parent
79ae5ff31c
commit
088e920f9d
7 changed files with 197 additions and 67 deletions
|
|
@ -12,7 +12,13 @@ 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 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")
|
_log = logging.getLogger("netx.ne.connect")
|
||||||
_executor: ThreadPoolExecutor | None = None
|
_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()
|
auth_mode = str(creds.get("hop_target_auth_mode") or "").strip()
|
||||||
if auth_mode:
|
if auth_mode:
|
||||||
lines.append(f"hop_target_auth_mode={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()
|
vrf = str(creds.get("hop_vrf") or "").strip()
|
||||||
if vrf:
|
if vrf:
|
||||||
lines.append(f"hop_vrf={vrf}")
|
lines.append(f"hop_vrf={vrf}")
|
||||||
|
|
@ -147,7 +164,16 @@ def _classify_connect_error(creds: dict[str, Any], exc: BaseException) -> str:
|
||||||
return detail
|
return detail
|
||||||
if "target_auth_timeout" in raw:
|
if "target_auth_timeout" in raw:
|
||||||
return "target_auth_failed: " + detail
|
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 "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:
|
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
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,39 @@ def default_huawei_hop_template(protocol: str, vrf: str = "") -> str:
|
||||||
|
|
||||||
|
|
||||||
def default_bastion_username_template() -> str:
|
def default_bastion_username_template() -> str:
|
||||||
"""SSH bastion composite username (JumpServer/CBH/generic protocol-proxy style)."""
|
"""SSH username sent to bastion (OpenSSH splits user@host at the last @)."""
|
||||||
return "{hop_user}@{target_user}@{target_ip}@{hop_host}"
|
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:
|
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
|
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.)."""
|
"""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]:
|
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:
|
if not prompt_list:
|
||||||
return []
|
return []
|
||||||
responses: list[str] = []
|
return [password] * len(prompt_list)
|
||||||
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 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(
|
def _bastion_ssh_connect(
|
||||||
|
|
@ -127,47 +171,64 @@ def _bastion_ssh_connect(
|
||||||
password: str,
|
password: str,
|
||||||
timeout: int,
|
timeout: int,
|
||||||
) -> paramiko.SSHClient:
|
) -> paramiko.SSHClient:
|
||||||
"""SSH to protocol-proxy bastion; interactive auth first (JumpServer/CBH/ZTE-TSM)."""
|
"""SSH to protocol-proxy bastion (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
|
|
||||||
|
|
||||||
handler = _bastion_interactive_handler(password)
|
Each strategy uses a fresh transport. Prefer password→keyboard-interactive
|
||||||
auth_errors: list[Exception] = []
|
fallback (OpenSSH-style) before a direct interactive attempt.
|
||||||
for use_interactive in (True, False):
|
"""
|
||||||
|
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:
|
try:
|
||||||
if use_interactive:
|
transport = _bastion_start_transport(host=host, port=port, timeout=timeout)
|
||||||
transport.auth_interactive(username, handler)
|
authenticate(transport)
|
||||||
else:
|
|
||||||
transport.auth_password(username, password, fallback=False)
|
|
||||||
if transport.is_authenticated():
|
if transport.is_authenticated():
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
client._transport = transport # noqa: SLF001
|
client._transport = transport # noqa: SLF001
|
||||||
return client
|
return client
|
||||||
except paramiko.BadAuthenticationType as exc:
|
except Exception as exc:
|
||||||
auth_errors.append(exc)
|
auth_errors.append((name, exc))
|
||||||
if use_interactive and "keyboard-interactive" not in getattr(exc, "allowed_types", ()):
|
finally:
|
||||||
continue
|
if transport is not None and not transport.is_authenticated():
|
||||||
except paramiko.AuthenticationException as exc:
|
try:
|
||||||
auth_errors.append(exc)
|
transport.close()
|
||||||
continue
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
preferred = next(
|
||||||
transport.close()
|
(
|
||||||
except Exception:
|
(name, exc)
|
||||||
pass
|
for name, exc in auth_errors
|
||||||
if auth_errors:
|
if isinstance(exc, paramiko.AuthenticationException)
|
||||||
raise auth_errors[-1]
|
and not isinstance(exc, paramiko.BadAuthenticationType)
|
||||||
raise paramiko.AuthenticationException("bastion_auth_failed")
|
),
|
||||||
|
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(
|
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:
|
if not hop_host or not hop_user or not hop_pass:
|
||||||
raise ValueError("hop_credentials_incomplete")
|
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"])
|
device_type = normalize_netmiko_device_type(creds["device_type"], creds["protocol"])
|
||||||
hop_port = int(creds.get("hop_port") or 22)
|
hop_port = int(creds.get("hop_port") or 22)
|
||||||
timeout = int(settings.ne_connect_timeout_sec or 30)
|
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(
|
ssh_client = _bastion_ssh_connect(
|
||||||
host=hop_host,
|
host=hop_host,
|
||||||
port=hop_port,
|
port=hop_port,
|
||||||
username=composite_user,
|
username=ssh_username,
|
||||||
password=hop_pass,
|
password=hop_pass,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
@ -378,7 +440,7 @@ def _connect_via_bastion(creds: dict[str, Any], *, session_timeout: int | None =
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
host=hop_host,
|
host=hop_host,
|
||||||
port=hop_port,
|
port=hop_port,
|
||||||
username=composite_user,
|
username=ssh_username,
|
||||||
password=hop_pass,
|
password=hop_pass,
|
||||||
enable_secret=str(creds.get("enable_secret") or ""),
|
enable_secret=str(creds.get("enable_secret") or ""),
|
||||||
session_timeout=session_timeout or 180,
|
session_timeout=session_timeout or 180,
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import unittest
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from netx_api.ne_session_factory import (
|
from netx_api.ne_session_factory import (
|
||||||
|
bastion_ssh_cli,
|
||||||
default_bastion_username_template,
|
default_bastion_username_template,
|
||||||
open_netmiko_connection,
|
open_netmiko_connection,
|
||||||
render_hop_command,
|
render_hop_command,
|
||||||
|
resolve_bastion_ssh_username,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -14,7 +16,7 @@ class BastionTemplateTests(unittest.TestCase):
|
||||||
def test_default_bastion_username_template(self) -> None:
|
def test_default_bastion_username_template(self) -> None:
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
default_bastion_username_template(),
|
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:
|
def test_render_bastion_composite_username(self) -> None:
|
||||||
|
|
@ -28,7 +30,7 @@ class BastionTemplateTests(unittest.TestCase):
|
||||||
"hop_vrf": "",
|
"hop_vrf": "",
|
||||||
}
|
}
|
||||||
out = render_hop_command("", creds)
|
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:
|
def test_render_custom_bastion_template(self) -> None:
|
||||||
creds = {
|
creds = {
|
||||||
|
|
@ -44,6 +46,24 @@ class BastionTemplateTests(unittest.TestCase):
|
||||||
out = render_hop_command(creds["hop_command_template"], creds)
|
out = render_hop_command(creds["hop_command_template"], creds)
|
||||||
self.assertEqual(out, "admin#root@5.6.7.8")
|
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):
|
class BastionConnectRoutingTests(unittest.TestCase):
|
||||||
@patch("netx_api.ne_session_factory._connect_via_bastion")
|
@patch("netx_api.ne_session_factory._connect_via_bastion")
|
||||||
|
|
@ -94,14 +114,14 @@ class BastionConnectImplTests(unittest.TestCase):
|
||||||
bastion_ssh.assert_called_once_with(
|
bastion_ssh.assert_called_once_with(
|
||||||
host="1.1.1.1",
|
host="1.1.1.1",
|
||||||
port=22,
|
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",
|
password="vault-pass",
|
||||||
timeout=unittest.mock.ANY,
|
timeout=unittest.mock.ANY,
|
||||||
)
|
)
|
||||||
netmiko_wrap.assert_called_once()
|
netmiko_wrap.assert_called_once()
|
||||||
wrap_kwargs = netmiko_wrap.call_args.kwargs
|
wrap_kwargs = netmiko_wrap.call_args.kwargs
|
||||||
self.assertEqual(wrap_kwargs["host"], "1.1.1.1")
|
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")
|
self.assertEqual(wrap_kwargs["password"], "vault-pass")
|
||||||
conn.disconnect.assert_not_called()
|
conn.disconnect.assert_not_called()
|
||||||
|
|
||||||
|
|
@ -131,8 +151,16 @@ class BastionConnectImplTests(unittest.TestCase):
|
||||||
"hop_vendor": "bastion",
|
"hop_vendor": "bastion",
|
||||||
"hop_protocol": "ssh",
|
"hop_protocol": "ssh",
|
||||||
"hop_vrf": "",
|
"hop_vrf": "",
|
||||||
|
"hop_command_template": "{hop_user}@{target_user}@{target_ip}@{hop_host}",
|
||||||
}
|
}
|
||||||
_connect_via_bastion(creds)
|
_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")
|
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:
|
def test_replies_to_vault_password_prompt(self) -> None:
|
||||||
from netx_api.ne_session_factory import _bastion_interactive_handler
|
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(
|
out = handler(
|
||||||
"Login",
|
"Login",
|
||||||
"",
|
"",
|
||||||
|
|
@ -148,19 +176,26 @@ class BastionInteractiveHandlerTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(out, ["vault-secret"])
|
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
|
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)])
|
out = handler("Login", "", [("Enter code:", False), ("Confirm:", False)])
|
||||||
self.assertEqual(out, ["vault-secret", "vault-secret"])
|
self.assertEqual(out, ["vault-secret", "vault-secret"])
|
||||||
|
|
||||||
def test_empty_prompt_list_returns_empty(self) -> None:
|
def test_empty_prompt_list_returns_empty(self) -> None:
|
||||||
from netx_api.ne_session_factory import _bastion_interactive_handler
|
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", "", []), [])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,7 @@ const en = {
|
||||||
"· password may be empty (required for direct login; optional for bastion-managed or batch proxy later).\n" +
|
"· 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" +
|
"· Recommended flow: import NEs first, select rows, then use Batch add proxy.\n\n" +
|
||||||
"[Jump / bastion]\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" +
|
"· 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" +
|
"· JumpServer/CBH often use port 2222; some sites use 22.\n\n" +
|
||||||
"[Connectivity / edit]\n" +
|
"[Connectivity / edit]\n" +
|
||||||
|
|
@ -240,7 +240,7 @@ const en = {
|
||||||
targetAuthHint: "Bastion-managed needs only bastion password; manual mode requires target NE password.",
|
targetAuthHint: "Bastion-managed needs only bastion password; manual mode requires target NE password.",
|
||||||
usernameTemplate: "SSH username template",
|
usernameTemplate: "SSH username template",
|
||||||
templateHintBastion:
|
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",
|
host: "Jump host",
|
||||||
port: "Jump port",
|
port: "Jump port",
|
||||||
protocol: "Jump protocol",
|
protocol: "Jump protocol",
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,7 @@ const zh = {
|
||||||
"· password 可留空(直连需填;堡垒机托管或后续批量添加代理时可空)。\n" +
|
"· password 可留空(直连需填;堡垒机托管或后续批量添加代理时可空)。\n" +
|
||||||
"· 推荐流程:先导入网元 → 勾选网元 → 点「批量添加代理」统一配置跳板/堡垒机。\n\n" +
|
"· 推荐流程:先导入网元 → 勾选网元 → 点「批量添加代理」统一配置跳板/堡垒机。\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" +
|
"· 堡垒机托管时填「跳板密码」(Vault 密码),目标密码可留空;手动模式需填目标密码。\n" +
|
||||||
"· JumpServer/CBH 常用跳板端口 2222,部分现场为 22。\n\n" +
|
"· JumpServer/CBH 常用跳板端口 2222,部分现场为 22。\n\n" +
|
||||||
"【连通性 / 编辑】\n" +
|
"【连通性 / 编辑】\n" +
|
||||||
|
|
@ -238,7 +238,7 @@ const zh = {
|
||||||
targetAuthHint: "堡垒机托管时仅需堡垒机密码;手动模式需填写目标网元密码。",
|
targetAuthHint: "堡垒机托管时仅需堡垒机密码;手动模式需填写目标网元密码。",
|
||||||
usernameTemplate: "SSH 用户名模板",
|
usernameTemplate: "SSH 用户名模板",
|
||||||
templateHintBastion:
|
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: "跳板地址",
|
host: "跳板地址",
|
||||||
port: "跳板端口",
|
port: "跳板端口",
|
||||||
protocol: "跳板协议",
|
protocol: "跳板协议",
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@ export type HopTargetAuthMode = "bastion_managed" | "manual";
|
||||||
export const HOP_VENDORS: HopVendor[] = ["zte", "huawei", "cisco", "linux", "bastion"];
|
export const HOP_VENDORS: HopVendor[] = ["zte", "huawei", "cisco", "linux", "bastion"];
|
||||||
|
|
||||||
export function bastionHopTemplate(): string {
|
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 {
|
export function isBastionHopVendor(vendor: string): boolean {
|
||||||
return String(vendor || "").toLowerCase() === "bastion";
|
return String(vendor || "").toLowerCase() === "bastion";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,11 @@ export function isAutoHopTemplate(
|
||||||
if (v === "huawei") return t === huaweiHopTemplate(protocol, vrf);
|
if (v === "huawei") return t === huaweiHopTemplate(protocol, vrf);
|
||||||
if (v === "cisco") return t === ciscoHopTemplate(protocol, vrf);
|
if (v === "cisco") return t === ciscoHopTemplate(protocol, vrf);
|
||||||
if (v === "linux") return t === "";
|
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);
|
return t === zteHopTemplate(protocol, vrf);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue