diff --git a/netx_api/main.py b/netx_api/main.py index 9992f2a..5db85f8 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -783,6 +783,9 @@ def on_startup() -> None: conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_password_enc TEXT DEFAULT ''") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_command_template TEXT DEFAULT ''") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_vrf VARCHAR(128) DEFAULT ''") + conn.exec_driver_sql( + "ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_target_auth_mode VARCHAR(32) DEFAULT 'bastion_managed'" + ) conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS connect_detail TEXT DEFAULT ''") conn.exec_driver_sql( "ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP" diff --git a/netx_api/models.py b/netx_api/models.py index a30e4d1..0f2c106 100644 --- a/netx_api/models.py +++ b/netx_api/models.py @@ -254,6 +254,7 @@ class ManagedNE(Base): hop_password_enc: Mapped[str] = mapped_column(Text, default="") hop_command_template: Mapped[str] = mapped_column(Text, default="") hop_vrf: Mapped[str] = mapped_column(String(128), default="") + hop_target_auth_mode: Mapped[str] = mapped_column(String(32), default="bastion_managed") created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) diff --git a/netx_api/ne_connect.py b/netx_api/ne_connect.py index e0e71ef..1d87a4e 100644 --- a/netx_api/ne_connect.py +++ b/netx_api/ne_connect.py @@ -47,6 +47,9 @@ def _connect_context_lines(creds: dict[str, Any]) -> list[str]: tpl = str(creds.get("hop_command_template") or "").strip() if tpl: lines.append(f"hop_command_template={tpl}") + auth_mode = str(creds.get("hop_target_auth_mode") or "").strip() + if auth_mode: + lines.append(f"hop_target_auth_mode={auth_mode}") vrf = str(creds.get("hop_vrf") or "").strip() if vrf: lines.append(f"hop_vrf={vrf}") @@ -150,7 +153,9 @@ def _classify_connect_error(creds: dict[str, Any], exc: BaseException) -> str: return "target_auth_failed: " + detail if "timed out" in raw or "timeout" in raw or "hop_connect_failed" in raw: return "hop_connect_failed: " + detail - if hop_v == "linux": + if hop_v in ("linux", "bastion"): + if "vault" in raw or "bastion" in raw: + return "bastion_auth_failed: " + detail return "hop_connect_failed: " + detail return "hop_command_failed: " + detail if "readtimeout" in raw.replace(" ", "") or "pattern not detected" in raw: diff --git a/netx_api/ne_schemas.py b/netx_api/ne_schemas.py index afc5b41..34f3fb5 100644 --- a/netx_api/ne_schemas.py +++ b/netx_api/ne_schemas.py @@ -30,6 +30,7 @@ class ManagedNeCreate(BaseModel): hop_password: str = "" hop_command_template: str = "" hop_vrf: str = "" + hop_target_auth_mode: str = "bastion_managed" @field_validator("vendor") @classmethod @@ -63,6 +64,7 @@ class ManagedNeUpdate(BaseModel): hop_password: str | None = None hop_command_template: str | None = None hop_vrf: str | None = None + hop_target_auth_mode: str | None = None @field_validator("vendor") @classmethod @@ -101,6 +103,7 @@ class ManagedNeOut(BaseModel): hop_username: str = "" hop_command_template: str = "" hop_vrf: str = "" + hop_target_auth_mode: str = "bastion_managed" created_at: datetime updated_at: datetime @@ -128,6 +131,7 @@ class HopProxyConfig(BaseModel): hop_password: str hop_command_template: str = "" hop_vrf: str = "" + hop_target_auth_mode: str = "bastion_managed" class BatchHopApplyRequest(BaseModel): diff --git a/netx_api/ne_service.py b/netx_api/ne_service.py index 2273447..6dcc19b 100644 --- a/netx_api/ne_service.py +++ b/netx_api/ne_service.py @@ -19,7 +19,7 @@ from .ne_schemas import ( ManagedNeOut, ManagedNeUpdate, ) -from .ne_session_factory import default_hop_command_template +from .ne_session_factory import default_bastion_username_template, default_hop_command_template IMPORT_COLUMNS = ( "device_type", @@ -53,7 +53,12 @@ def _normalize_protocol(protocol: str) -> str: def _normalize_hop_vendor(vendor: str) -> str: v = str(vendor or "zte").strip().lower() - return v if v in ("zte", "linux", "huawei", "cisco") else "zte" + return v if v in ("zte", "linux", "huawei", "cisco", "bastion") else "zte" + + +def _normalize_hop_target_auth_mode(mode: str) -> str: + m = str(mode or "bastion_managed").strip().lower() + return m if m in ("bastion_managed", "manual") else "bastion_managed" def _validate_hop_on_create(body: ManagedNeCreate) -> None: @@ -65,6 +70,18 @@ def _validate_hop_on_create(body: ManagedNeCreate) -> None: raise HTTPException(status_code=400, detail="hop_username_required") if not str(body.hop_password or "").strip(): raise HTTPException(status_code=400, detail="hop_password_required") + hop_vendor = _normalize_hop_vendor(body.hop_vendor) + if hop_vendor == "bastion" and _normalize_hop_target_auth_mode(body.hop_target_auth_mode) == "manual": + if not str(body.password or "").strip(): + raise HTTPException(status_code=400, detail="password_required") + + +def _target_password_optional(body: ManagedNeCreate) -> bool: + return ( + bool(body.hop_enabled) + and _normalize_hop_vendor(body.hop_vendor) == "bastion" + and _normalize_hop_target_auth_mode(body.hop_target_auth_mode) == "bastion_managed" + ) def _apply_hop_create(row: ManagedNE, body: ManagedNeCreate) -> None: @@ -77,6 +94,7 @@ def _apply_hop_create(row: ManagedNE, body: ManagedNeCreate) -> None: row.hop_password_enc = encrypt_secret(body.hop_password) if body.hop_enabled else "" row.hop_command_template = str(body.hop_command_template or "").strip() row.hop_vrf = str(body.hop_vrf or "").strip() + row.hop_target_auth_mode = _normalize_hop_target_auth_mode(body.hop_target_auth_mode) def _apply_hop_update(row: ManagedNE, data: dict[str, Any]) -> None: @@ -99,6 +117,8 @@ def _apply_hop_update(row: ManagedNE, data: dict[str, Any]) -> None: row.hop_command_template = str(data["hop_command_template"]).strip() if "hop_vrf" in data and data["hop_vrf"] is not None: row.hop_vrf = str(data["hop_vrf"]).strip() + if "hop_target_auth_mode" in data and data["hop_target_auth_mode"] is not None: + row.hop_target_auth_mode = _normalize_hop_target_auth_mode(data["hop_target_auth_mode"]) if row.hop_enabled: if not str(row.hop_host or "").strip(): raise HTTPException(status_code=400, detail="hop_host_required") @@ -135,6 +155,7 @@ def row_to_out(row: ManagedNE) -> ManagedNeOut: hop_username=str(row.hop_username or ""), hop_command_template=str(row.hop_command_template or ""), hop_vrf=str(row.hop_vrf or ""), + hop_target_auth_mode=str(row.hop_target_auth_mode or "bastion_managed"), created_at=row.created_at, updated_at=row.updated_at, ) @@ -189,6 +210,8 @@ def get_managed_ne(db: Session, ne_id: str) -> ManagedNeOut: def create_managed_ne(db: Session, body: ManagedNeCreate) -> ManagedNeOut: _require_crypto() _validate_hop_on_create(body) + if not str(body.password or "").strip() and not _target_password_optional(body): + raise HTTPException(status_code=400, detail="password_required") ip = _normalize_ip(body.ip_address) if not ip: raise HTTPException(status_code=400, detail="ip_address_required") @@ -206,7 +229,7 @@ def create_managed_ne(db: Session, body: ManagedNeCreate) -> ManagedNeOut: port=int(body.port or 22), protocol=_normalize_protocol(body.protocol), username=str(body.username or "").strip(), - password_enc=encrypt_secret(body.password), + password_enc=encrypt_secret(body.password) if str(body.password or "").strip() else "", enable_secret_enc="", connect_status="unknown", tags=str(body.tags or "").strip(), @@ -266,6 +289,7 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed "hop_password", "hop_command_template", "hop_vrf", + "hop_target_auth_mode", ) if any(k in data for k in hop_keys): _apply_hop_update(row, data) @@ -290,7 +314,9 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d hop_vendor = _normalize_hop_vendor(hop.hop_vendor) template = str(hop.hop_command_template or "").strip() - if hop_vendor != "linux" and not template: + if hop_vendor == "bastion" and not template: + template = default_bastion_username_template() + elif hop_vendor not in ("linux", "bastion") and not template: template = default_hop_command_template(hop_vendor, hop.hop_protocol, hop.hop_vrf) ne_ids = [str(x).strip() for x in ids if str(x).strip()] @@ -315,6 +341,7 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d row.hop_password_enc = enc row.hop_command_template = template row.hop_vrf = str(hop.hop_vrf or "").strip() + row.hop_target_auth_mode = _normalize_hop_target_auth_mode(hop.hop_target_auth_mode) row.updated_at = now db.commit() return {"ok": True, "updated": len(rows)} @@ -484,4 +511,5 @@ def get_device_credentials(row: ManagedNE) -> dict[str, Any]: "hop_password": hop_password, "hop_command_template": str(row.hop_command_template or ""), "hop_vrf": str(row.hop_vrf or ""), + "hop_target_auth_mode": str(row.hop_target_auth_mode or "bastion_managed"), } diff --git a/netx_api/ne_session_factory.py b/netx_api/ne_session_factory.py index cc1bfde..dc252c8 100644 --- a/netx_api/ne_session_factory.py +++ b/netx_api/ne_session_factory.py @@ -15,7 +15,7 @@ from .ne_netmiko import normalize_netmiko_device_type _log = logging.getLogger("netx.ne.session") -_HOP_PLACEHOLDERS = ("target_ip", "target_port", "target_user", "target_password", "vrf") +_HOP_PLACEHOLDERS = ("target_ip", "target_port", "target_user", "target_password", "vrf", "hop_user", "hop_host") # ZTE CLI jump: ssh/telnet [vrf ] — target user/password via secondary auth. _LEGACY_HOP_TEMPLATES = frozenset({"ssh {target_user}@{target_ip}", "ssh {target_ip}", "telnet {target_ip}"}) @@ -53,8 +53,15 @@ def default_huawei_hop_template(protocol: str, vrf: str = "") -> str: return "stelnet {target_ip}" +def default_bastion_username_template() -> str: + """SSH bastion composite username (JumpServer/CBH/ZTE-TSM style).""" + return "{hop_user}@{target_user}@{target_ip}@{hop_host}" + + def default_hop_command_template(vendor: str, protocol: str, vrf: str = "") -> str: v = str(vendor or "zte").strip().lower() + if v == "bastion": + return default_bastion_username_template() if v == "huawei": return default_huawei_hop_template(protocol, vrf) if v == "cisco": @@ -81,6 +88,8 @@ def render_hop_command(template: str, creds: dict[str, Any]) -> str: "target_user": str(creds.get("username") or ""), "target_password": str(creds.get("password") or ""), "vrf": str(creds.get("hop_vrf") or "").strip(), + "hop_user": str(creds.get("hop_username") or ""), + "hop_host": str(creds.get("hop_host") or "").strip(), } out = tpl for key in _HOP_PLACEHOLDERS: @@ -233,6 +242,42 @@ def _connect_via_cli_hop(creds: dict[str, Any], *, session_timeout: int | None = raise +def _connect_via_bastion(creds: dict[str, Any], *, session_timeout: int | None = None) -> ConnectHandler: + """SSH to bastion with composite username; bastion proxies to target (protocol proxy).""" + 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") + + composite_user = render_hop_command(str(creds.get("hop_command_template") or ""), creds) + device_type = normalize_netmiko_device_type(creds["device_type"], creds["protocol"]) + hop_dev = _base_connect_kwargs( + device_type=device_type, + host=hop_host, + port=int(creds.get("hop_port") or 22), + username=composite_user, + password=hop_pass, + enable_secret=str(creds.get("enable_secret") or ""), + session_timeout=session_timeout or 180, + ) + conn = ConnectHandler(**hop_dev) + auth_mode = str(creds.get("hop_target_auth_mode") or "bastion_managed").strip().lower() + if auth_mode == "manual": + target_pass = str(creds.get("password") or "") + if target_pass: + try: + _read_channel(conn, wait=0.5) + _interactive_target_auth(conn, str(creds["username"]), target_pass) + except Exception: + try: + conn.disconnect() + except Exception: + pass + raise + return conn + + 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() @@ -311,7 +356,10 @@ def close_netmiko_connection(conn: ConnectHandler | None) -> None: 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).""" if creds.get("hop_enabled"): - if _hop_vendor(creds) == "linux": + vendor = _hop_vendor(creds) + if vendor == "linux": return _connect_via_linux_hop(creds, session_timeout=session_timeout) + if vendor == "bastion": + return _connect_via_bastion(creds, session_timeout=session_timeout) return _connect_via_cli_hop(creds, session_timeout=session_timeout) return _connect_direct(creds, session_timeout=session_timeout) diff --git a/tests/test_bastion_hop.py b/tests/test_bastion_hop.py new file mode 100644 index 0000000..f931860 --- /dev/null +++ b/tests/test_bastion_hop.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock, patch + +from netx_api.ne_session_factory import ( + default_bastion_username_template, + open_netmiko_connection, + render_hop_command, +) + + +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}", + ) + + def test_render_bastion_composite_username(self) -> None: + creds = { + "hop_vendor": "bastion", + "hop_username": "ZTE-TSM", + "hop_host": "10.34.145.27", + "username": "ca-oper", + "ip_address": "114.0.44.11", + "hop_protocol": "ssh", + "hop_vrf": "", + } + out = render_hop_command("", creds) + self.assertEqual(out, "ZTE-TSM@ca-oper@114.0.44.11@10.34.145.27") + + def test_render_custom_bastion_template(self) -> None: + creds = { + "hop_vendor": "bastion", + "hop_username": "admin", + "hop_host": "1.2.3.4", + "username": "root", + "ip_address": "5.6.7.8", + "hop_command_template": "{hop_user}#{target_user}@{target_ip}", + "hop_protocol": "ssh", + "hop_vrf": "", + } + out = render_hop_command(creds["hop_command_template"], creds) + self.assertEqual(out, "admin#root@5.6.7.8") + + +class BastionConnectRoutingTests(unittest.TestCase): + @patch("netx_api.ne_session_factory._connect_via_bastion") + @patch("netx_api.ne_session_factory._connect_direct") + def test_open_routes_to_bastion_when_enabled(self, direct, bastion) -> None: + bastion.return_value = MagicMock() + creds = {"hop_enabled": True, "hop_vendor": "bastion"} + open_netmiko_connection(creds) + bastion.assert_called_once() + direct.assert_not_called() + + @patch("netx_api.ne_session_factory._connect_via_bastion") + @patch("netx_api.ne_session_factory._connect_via_linux_hop") + def test_open_routes_linux_not_bastion(self, linux, bastion) -> None: + linux.return_value = MagicMock() + creds = {"hop_enabled": True, "hop_vendor": "linux"} + open_netmiko_connection(creds) + linux.assert_called_once() + bastion.assert_not_called() + + +class BastionConnectImplTests(unittest.TestCase): + @patch("netx_api.ne_session_factory.ConnectHandler") + def test_bastion_managed_skips_secondary_auth(self, connect_handler) -> None: + from netx_api.ne_session_factory import _connect_via_bastion + + conn = MagicMock() + connect_handler.return_value = conn + creds = { + "hop_host": "10.34.145.27", + "hop_username": "ZTE-TSM", + "hop_password": "vault-pass", + "hop_port": 22, + "device_type": "zte_zxros", + "protocol": "ssh", + "username": "ca-oper", + "ip_address": "114.0.44.11", + "password": "", + "hop_target_auth_mode": "bastion_managed", + "hop_vendor": "bastion", + "hop_protocol": "ssh", + "hop_vrf": "", + } + _connect_via_bastion(creds) + kwargs = connect_handler.call_args.kwargs + self.assertEqual(kwargs["host"], "10.34.145.27") + self.assertEqual(kwargs["username"], "ZTE-TSM@ca-oper@114.0.44.11@10.34.145.27") + self.assertEqual(kwargs["password"], "vault-pass") + conn.disconnect.assert_not_called() + + @patch("netx_api.ne_session_factory._interactive_target_auth") + @patch("netx_api.ne_session_factory._read_channel") + @patch("netx_api.ne_session_factory.ConnectHandler") + def test_bastion_manual_invokes_secondary_auth(self, connect_handler, _read, interact) -> None: + from netx_api.ne_session_factory import _connect_via_bastion + + conn = MagicMock() + connect_handler.return_value = conn + creds = { + "hop_host": "10.0.0.1", + "hop_username": "bastion-user", + "hop_password": "bastion-pass", + "hop_port": 2222, + "device_type": "cisco_ios", + "protocol": "ssh", + "username": "target-user", + "ip_address": "10.0.0.2", + "password": "target-pass", + "hop_target_auth_mode": "manual", + "hop_vendor": "bastion", + "hop_protocol": "ssh", + "hop_vrf": "", + } + _connect_via_bastion(creds) + interact.assert_called_once_with(conn, "target-user", "target-pass") + + +if __name__ == "__main__": + unittest.main() diff --git a/web/src/components/HopProxyFields.tsx b/web/src/components/HopProxyFields.tsx index cfefb55..073370f 100644 --- a/web/src/components/HopProxyFields.tsx +++ b/web/src/components/HopProxyFields.tsx @@ -4,8 +4,10 @@ import { HOP_VENDORS, defaultHopTemplate, isAutoHopTemplate, + isBastionHopVendor, isLinuxHopVendor, patchHopVendorChange, + type HopTargetAuthMode, type HopVendor, } from "../utils/hopProxy"; @@ -18,6 +20,7 @@ export type HopProxyFieldsState = { hop_password: string; hop_command_template: string; hop_vrf: string; + hop_target_auth_mode: HopTargetAuthMode; }; export const emptyHopProxyFields = (): HopProxyFieldsState => ({ @@ -29,6 +32,7 @@ export const emptyHopProxyFields = (): HopProxyFieldsState => ({ hop_password: "", hop_command_template: defaultHopTemplate("zte", "ssh", ""), hop_vrf: "", + hop_target_auth_mode: "bastion_managed", }); function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) { @@ -59,6 +63,7 @@ function applyHopTemplate( function hopHintKey(vendor: string): string { const v = String(vendor || "").toLowerCase(); + if (v === "bastion") return "managedNe.hop.bastionHint"; if (v === "linux") return "managedNe.hop.linuxHint"; if (v === "huawei") return "managedNe.hop.huaweiHint"; if (v === "cisco") return "managedNe.hop.ciscoHint"; @@ -67,6 +72,7 @@ function hopHintKey(vendor: string): string { function templateHintKey(vendor: string): string { const v = String(vendor || "").toLowerCase(); + if (v === "bastion") return "managedNe.hop.templateHintBastion"; if (v === "huawei") return "managedNe.hop.templateHintHuawei"; if (v === "cisco") return "managedNe.hop.templateHintCisco"; return "managedNe.hop.templateHint"; @@ -94,7 +100,9 @@ export function HopProxyFields({ }: Props) { const { t } = useI18n(); const linux = isLinuxHopVendor(value.hop_vendor); + const bastion = isBastionHopVendor(value.hop_vendor); const huawei = value.hop_vendor === "huawei"; + const cliHop = !linux && !bastion; const set = (patch: Partial) => onChange(patch); @@ -129,7 +137,20 @@ export function HopProxyFields({ onChange={(e) => set({ hop_port: Number(e.target.value) || 22 })} /> - {!linux ? ( + {bastion ? ( + + ) : null} + {cliHop ? ( + ) : null} + {cliHop ? ( <>