netx/netx_api/ne_service_webcrt.py
oliver 241093343e Add topology-canvas placeholder NE create (source=topology).
Toolbar and pane context menu create placeholders that follow the same setup path as LLDP in NE management and WebCRT.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:42:51 +08:00

276 lines
9.7 KiB
Python

"""WebCRT managed-NE host upsert helpers."""
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from sqlalchemy.orm import Session
from .device_types import WEBCRT_DEVICE_TYPES, WEBCRT_NE_SOURCE, is_placeholder_ne_source
from .models import ManagedNE
from .ne_crypto import encrypt_secret
from .ne_schemas import ManagedNeCreate, ManagedNeOut
from .ne_service_common import (
WEBCRT_SOURCE,
_apply_hop_create,
_normalize_hop_target_auth_mode,
_normalize_hop_vendor,
_normalize_ip,
_normalize_protocol,
_normalize_vendor,
_now,
_require_crypto,
_validate_hop_on_create,
row_to_out,
)
def _is_webcrt_claimable(row: ManagedNE) -> bool:
"""LLDP / topology placeholders / incomplete rows (no IP) can be promoted into WebCRT sessions."""
src = str(row.source or "").strip().lower()
if is_placeholder_ne_source(src):
return True
if src in {WEBCRT_NE_SOURCE, "webcrt", "ume_sync"}:
return False
return not str(row.ip_address or "").strip()
def _normalize_webcrt_device_type(device_type: str) -> str:
dt = str(device_type or "").strip()
low = dt.lower()
if low in ("linux", "linux_ssh", "linux_telnet"):
return "linux"
if low in ("generic", "generic_ssh", "generic_telnet", "terminal_server", "generic_termserver"):
return "generic"
return dt
def upsert_webcrt_managed_ne(db: Session, body: ManagedNeCreate) -> tuple[ManagedNeOut, str]:
"""Create/update a WebCRT-origin NE, or reuse an existing inventory NE by IP.
Returns ``(ne_out, action)`` where action is ``created`` | ``updated`` | ``reused``.
"""
_require_crypto()
_validate_hop_on_create(body)
ip = _normalize_ip(body.ip_address)
if not ip:
raise HTTPException(status_code=400, detail="ip_address_required")
if not str(body.username or "").strip():
raise HTTPException(status_code=400, detail="cli_username_required")
device_type = _normalize_webcrt_device_type(body.device_type)
if device_type not in WEBCRT_DEVICE_TYPES:
raise HTTPException(status_code=400, detail="unsupported_device_type")
existing = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).first()
now = _now()
if existing is not None:
src = str(existing.source or "").strip()
if src != WEBCRT_NE_SOURCE:
# Do not overwrite inventory / UME-synced assets; just open them.
return row_to_out(existing), "reused"
existing.name = str(body.name or "").strip() or existing.name or ip
existing.vendor = _normalize_vendor(body.vendor) if str(body.vendor or "").strip() else (
"Other" if device_type == "linux" else existing.vendor
)
existing.device_type = device_type
existing.port = int(body.port or existing.port or 22)
existing.protocol = _normalize_protocol(body.protocol)
existing.username = str(body.username or "").strip()
if str(body.password or "").strip():
existing.password_enc = encrypt_secret(body.password)
elif not str(existing.password_enc or "").strip() and not (
body.hop_enabled
and _normalize_hop_vendor(body.hop_vendor) == "bastion"
and _normalize_hop_target_auth_mode(body.hop_target_auth_mode) == "bastion_managed"
):
raise HTTPException(status_code=400, detail="password_required")
existing.source = WEBCRT_NE_SOURCE
_apply_hop_create(existing, body)
existing.updated_at = now
db.commit()
db.refresh(existing)
return row_to_out(existing), "updated"
if not str(body.password or "").strip() and not (
body.hop_enabled
and _normalize_hop_vendor(body.hop_vendor) == "bastion"
and _normalize_hop_target_auth_mode(body.hop_target_auth_mode) == "bastion_managed"
):
raise HTTPException(status_code=400, detail="password_required")
vendor = _normalize_vendor(body.vendor)
if device_type == "linux" and not str(body.vendor or "").strip():
vendor = "Other"
row = ManagedNE(
name=str(body.name or "").strip() or ip,
vendor=vendor,
device_type=device_type,
ip_address=ip,
port=int(body.port or 22),
protocol=_normalize_protocol(body.protocol),
username=str(body.username or "").strip(),
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(),
remark=str(body.remark or "").strip(),
source=WEBCRT_NE_SOURCE,
source_ref="",
created_at=now,
updated_at=now,
)
_apply_hop_create(row, body)
db.add(row)
db.commit()
db.refresh(row)
return row_to_out(row), "created"
def _next_webcrt_session_name(db: Session, base: str) -> str:
"""Return base, or ``base (1)``, ``base (2)``, … among WebCRT session names."""
root = str(base or "").strip() or "session"
rows = (
db.query(ManagedNE.name)
.filter(ManagedNE.source == WEBCRT_NE_SOURCE)
.all()
)
taken = {str(r[0] or "").strip() for r in rows if str(r[0] or "").strip()}
if root not in taken:
return root
n = 1
while f"{root} ({n})" in taken:
n += 1
return f"{root} ({n})"
def upsert_webcrt_session_host(
db: Session,
*,
name: str = "",
ip_address: str,
port: int = 22,
protocol: str = "ssh",
username: str = "",
password: str = "",
save_password: bool = False,
ne_id: str | None = None,
) -> tuple[ManagedNeOut, str]:
"""Create a WebCRT session host, or claim an existing LLDP / incomplete ManagedNE.
Without ``ne_id``: always inserts a new row. Same IP is allowed; session name
auto-suffixes ``(1)``, ``(2)``, … on collision among WebCRT hosts.
With ``ne_id``: updates that claimable row in place and sets ``source=webcrt``.
Telnet never persists a password. SSH persists password only when ``save_password``.
Returns ``(ne_out, \"created\" | \"updated\")``.
"""
_require_crypto()
ip = _normalize_ip(ip_address)
if not ip:
raise HTTPException(status_code=400, detail="ip_address_required")
proto = _normalize_protocol(protocol)
user = str(username or "").strip()
pwd = str(password or "")
if proto == "ssh" and not user:
raise HTTPException(status_code=400, detail="cli_username_required")
if proto == "ssh" and save_password and not pwd.strip():
raise HTTPException(status_code=400, detail="password_required")
now = _now()
port_n = int(port or (23 if proto == "telnet" else 22))
password_enc = ""
if proto == "ssh" and save_password and pwd.strip():
password_enc = encrypt_secret(pwd)
claim_id = str(ne_id or "").strip()
if claim_id:
row = db.get(ManagedNE, claim_id)
if row is None:
raise HTTPException(status_code=404, detail="managed_ne_not_found")
if not _is_webcrt_claimable(row):
raise HTTPException(status_code=400, detail="ne_not_claimable_for_webcrt")
preferred = str(name or "").strip() or str(row.name or "").strip() or ip
# Keep current name when unchanged; otherwise uniquify among other WebCRT names.
if preferred == str(row.name or "").strip():
display_name = preferred
else:
display_name = _next_webcrt_session_name(db, preferred)
row.name = display_name
row.ip_address = ip
row.port = port_n
row.protocol = proto
row.username = user
if proto == "ssh":
if save_password and password_enc:
row.password_enc = password_enc
elif not save_password:
# One-shot auth; clear incomplete placeholder creds.
row.password_enc = ""
else:
row.password_enc = ""
row.device_type = "generic"
if not str(row.vendor or "").strip():
row.vendor = "Other"
row.source = WEBCRT_NE_SOURCE
row.connect_status = "unknown"
row.updated_at = now
from .topology_fabric_nodes import ensure_fabric_node_for_managed
ensure_fabric_node_for_managed(db, row)
try:
db.commit()
except Exception as exc:
db.rollback()
from sqlalchemy.exc import IntegrityError
if isinstance(exc, IntegrityError):
raise HTTPException(
status_code=409,
detail="ip_address_conflict_restart_required",
) from exc
raise
db.refresh(row)
return row_to_out(row), "updated"
display_name = _next_webcrt_session_name(db, str(name or "").strip() or ip)
row = ManagedNE(
name=display_name,
vendor="Other",
# generic → Netmiko terminal_server: SSH auth then raw PTY (no linux session prep).
device_type="generic",
ip_address=ip,
port=port_n,
protocol=proto,
username=user,
password_enc=password_enc,
enable_secret_enc="",
connect_status="unknown",
tags="",
remark="",
source=WEBCRT_NE_SOURCE,
source_ref="",
created_at=now,
updated_at=now,
)
db.add(row)
try:
db.commit()
except Exception as exc:
db.rollback()
# Stale unique index on ip_address → restart API after migration, or drop constraint manually.
from sqlalchemy.exc import IntegrityError
if isinstance(exc, IntegrityError):
raise HTTPException(
status_code=409,
detail="ip_address_conflict_restart_required",
) from exc
raise
db.refresh(row)
return row_to_out(row), "created"