feat(managed-ne): add bastion SSH protocol proxy hop type

Support composite-username bastion login for automated connect-test and exec, with bastion-managed or manual target credential modes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-05 10:47:52 +08:00
parent e62f4f2c74
commit d3d7f62a02
15 changed files with 348 additions and 19 deletions

View file

@ -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"

View file

@ -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)

View file

@ -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:

View file

@ -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):

View file

@ -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"),
}

View file

@ -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 <ip> [vrf <name>] — 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)