netx/netx_api/ne_service_common.py
oliver 312f6f2f96 Improve managed NE filters/validation and simplify the workbench UI.
Add column filters and clearer API error mapping for NE management, tighten port validation, and slim the workbench into reusable modules with cleaner styles.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 00:26:58 +08:00

346 lines
14 KiB
Python

"""Managed NE shared helpers, constants, and credential extraction."""
from __future__ import annotations
from datetime import datetime
import re
from typing import Any
import pandas as pd
from fastapi import HTTPException
from sqlalchemy.orm import Session
from .device_types import (
SUPPORTED_DEVICE_TYPES,
SUPPORTED_VENDORS,
WEBCRT_DEVICE_TYPES,
WEBCRT_NE_SOURCE,
)
from .models import ManagedNE
from .ne_crypto import CredentialCryptoError, credentials_configured, decrypt_secret, encrypt_secret
from .ne_schemas import ManagedNeCreate, ManagedNeOut, ManagedNeUpdate
from .ne_hop_templates import expand_bastion_hop_fields, normalize_hop_host
from .ne_session_factory import default_bastion_username_template, default_hop_command_template
from .ne_exec_guard import normalize_exec_policy
from .timeutil import utcnow_naive
IMPORT_COLUMNS = (
"device_type",
"ip",
"username",
"password",
"port",
"protocol",
"name",
"vendor",
"tags",
"remark",
)
UME_SYNC_SOURCE = "ume_sync"
UME_SYNC_TAG = "UME"
# Re-export for callers (WebCRT quick-connect).
WEBCRT_SOURCE = WEBCRT_NE_SOURCE
_BUILTIN_NE_TYPE_RULES: list[tuple[re.Pattern[str], str, str]] = [
(re.compile(r"ZXR|ZXCTN|M6000|\bBN\b", re.I), "zte_zxros", "ZTE"),
(re.compile(r"NE40|CE\b|ATN|MA5800|OptiX", re.I), "huawei", "Huawei"),
(re.compile(r"ASR|NCS|IOS.?XR|XR\b", re.I), "cisco_xr", "Cisco"),
(re.compile(r"Catalyst|Nexus|C9[0-9]{3}|ISR", re.I), "cisco_ios", "Cisco"),
]
def _now() -> datetime:
return utcnow_naive()
def _require_crypto() -> None:
if not credentials_configured():
raise HTTPException(status_code=503, detail="credential_secret_key_not_configured")
def _normalize_ip(ip: str) -> str:
return str(ip or "").strip()
_HOSTNAME_RE = re.compile(r"^(?=.{1,253}$)[A-Za-z0-9_](?:[A-Za-z0-9_-]{0,62})(?:\.[A-Za-z0-9_](?:[A-Za-z0-9_-]{0,62}))*\.?$")
def _is_valid_mgmt_address(ip: str) -> bool:
"""Accept an IPv4/IPv6 literal or a DNS hostname (no spaces, ports or schemes)."""
import ipaddress
text = str(ip or "").strip()
if not text:
return False
try:
ipaddress.ip_address(text)
return True
except ValueError:
pass
# Dotted-quad lookalikes (e.g. 10.0.0.300) are typos, not hostnames.
if re.fullmatch(r"[0-9.]+", text):
return False
return bool(_HOSTNAME_RE.match(text))
def _require_mgmt_address(ip: str) -> str:
text = _normalize_ip(ip)
if not text:
raise HTTPException(status_code=400, detail="ip_address_required")
if not _is_valid_mgmt_address(text):
raise HTTPException(status_code=400, detail="invalid_ip_address")
return text
CONNECT_FIELDS = (
"ip_address", "port", "protocol", "username", "password_enc", "device_type",
"hop_enabled", "hop_vendor", "hop_host", "hop_port", "hop_protocol", "hop_username",
"hop_password_enc", "hop_command_template", "hop_vrf", "hop_target_auth_mode",
"hop_enter_system_view",
)
def connect_snapshot(row: ManagedNE) -> tuple[Any, ...]:
return tuple(getattr(row, f, None) for f in CONNECT_FIELDS)
def reset_connect_status_if_changed(row: ManagedNE, before: tuple[Any, ...]) -> None:
"""A previous pass/fail no longer describes the device once login settings change."""
if connect_snapshot(row) != before and str(row.connect_status or "") != "unknown":
row.connect_status = "unknown"
row.connect_message = ""
def hop_password_optional(hop_vendor: str, hop_target_auth_mode: str) -> bool:
"""Only a bastion that manages target auth may run without a stored hop password."""
return (
_normalize_hop_vendor(hop_vendor) == "bastion"
and _normalize_hop_target_auth_mode(hop_target_auth_mode) == "bastion_managed"
)
def _normalize_protocol(protocol: str) -> str:
p = str(protocol or "ssh").strip().lower()
return p if p in ("ssh", "telnet") else "ssh"
def _normalize_hop_vendor(vendor: str) -> str:
v = str(vendor or "zte").strip().lower()
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 _normalize_vendor(vendor: str) -> str:
raw = str(vendor or "").strip()
if not raw:
return "Other"
for item in SUPPORTED_VENDORS:
if item.lower() == raw.lower():
return item
return "Other"
def _merge_tags(tags: str, *extras: str) -> str:
seen: set[str] = set()
out: list[str] = []
for token in str(tags or "").split():
t = token.strip()
if t and t not in seen:
seen.add(t)
out.append(t)
for extra in extras:
t = str(extra or "").strip()
if t and t not in seen:
seen.add(t)
out.append(t)
return " ".join(out)
def _infer_managed_ne_type_vendor(ne_type: str, vendor: str) -> tuple[str, str]:
raw_vendor = _normalize_vendor(vendor)
text = str(ne_type or "").strip()
for pattern, device_type, inferred_vendor in _BUILTIN_NE_TYPE_RULES:
if pattern.search(text):
dt = device_type if device_type in SUPPORTED_DEVICE_TYPES else "zte_zxros"
return dt, _normalize_vendor(inferred_vendor or raw_vendor)
if raw_vendor == "Huawei":
return "huawei", "Huawei"
if raw_vendor == "Cisco":
return "cisco_ios", "Cisco"
if raw_vendor == "ZTE":
return "zte_zxros", "ZTE"
return "zte_zxros", raw_vendor
def _validate_hop_on_create(body: ManagedNeCreate) -> None:
if not body.hop_enabled:
return
hop_vendor = _normalize_hop_vendor(body.hop_vendor)
hop_host, hop_username = _normalize_saved_hop_endpoint(
hop_vendor=hop_vendor,
hop_host=str(body.hop_host or ""),
hop_username=str(body.hop_username or ""),
)
if not hop_host:
raise HTTPException(status_code=400, detail="hop_host_required")
if not hop_username:
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")
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 _parse_import_bool(value: Any) -> bool:
return str(value or "").strip().lower() in ("1", "true", "yes", "y", "on")
def _import_cell_str(value: Any) -> str:
if value is None or (isinstance(value, float) and pd.isna(value)):
return ""
text = str(value).strip()
return "" if text.lower() == "nan" else text
def _normalize_saved_hop_endpoint(*, hop_vendor: str, hop_host: str, hop_username: str) -> tuple[str, str]:
"""Accept IP or FQDN; split pasted OpenSSH ``user@target@ip@bastion`` into fields."""
host = str(hop_host or "").strip()
user = str(hop_username or "").strip()
if str(hop_vendor or "").strip().lower() == "bastion":
return expand_bastion_hop_fields(hop_host=host, hop_username=user)
return normalize_hop_host(host), user
def _apply_hop_create(row: ManagedNE, body: ManagedNeCreate) -> None:
row.hop_enabled = bool(body.hop_enabled)
row.hop_vendor = _normalize_hop_vendor(body.hop_vendor)
hop_host, hop_username = _normalize_saved_hop_endpoint(
hop_vendor=row.hop_vendor,
hop_host=str(body.hop_host or ""),
hop_username=str(body.hop_username or ""),
)
row.hop_host = hop_host
row.hop_port = int(body.hop_port or 22)
row.hop_protocol = _normalize_protocol(body.hop_protocol)
row.hop_username = hop_username
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)
row.hop_enter_system_view = bool(body.hop_enter_system_view)
def _apply_hop_update(row: ManagedNE, data: dict[str, Any]) -> None:
if "hop_enabled" in data and data["hop_enabled"] is not None:
row.hop_enabled = bool(data["hop_enabled"])
if "hop_vendor" in data and data["hop_vendor"] is not None:
row.hop_vendor = _normalize_hop_vendor(data["hop_vendor"])
if "hop_host" in data and data["hop_host"] is not None:
row.hop_host = str(data["hop_host"]).strip()
if "hop_port" in data and data["hop_port"] is not None:
row.hop_port = int(data["hop_port"])
if "hop_protocol" in data and data["hop_protocol"] is not None:
row.hop_protocol = _normalize_protocol(data["hop_protocol"])
if "hop_username" in data and data["hop_username"] is not None:
row.hop_username = str(data["hop_username"]).strip()
if "hop_password" in data and data["hop_password"]:
_require_crypto()
row.hop_password_enc = encrypt_secret(str(data["hop_password"]))
if "hop_command_template" in data and data["hop_command_template"] is not 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 "hop_enter_system_view" in data and data["hop_enter_system_view"] is not None:
row.hop_enter_system_view = bool(data["hop_enter_system_view"])
if "hop_host" in data or "hop_username" in data or "hop_vendor" in data:
hop_host, hop_username = _normalize_saved_hop_endpoint(
hop_vendor=str(row.hop_vendor or ""),
hop_host=str(row.hop_host or ""),
hop_username=str(row.hop_username or ""),
)
row.hop_host = hop_host
row.hop_username = hop_username
if row.hop_enabled:
if not str(row.hop_host or "").strip():
raise HTTPException(status_code=400, detail="hop_host_required")
if not str(row.hop_username or "").strip():
raise HTTPException(status_code=400, detail="hop_username_required")
if not str(row.hop_password_enc or "").strip() and not hop_password_optional(
str(row.hop_vendor or ""), str(row.hop_target_auth_mode or "")
):
raise HTTPException(status_code=400, detail="hop_password_required")
def row_to_out(row: ManagedNE) -> ManagedNeOut:
status = str(row.connect_status or "unknown")
if status not in ("unknown", "testing", "pass", "fail"):
status = "unknown"
return ManagedNeOut(
id=str(row.id),
name=str(row.name or ""),
vendor=str(row.vendor or "Other"),
device_type=str(row.device_type or ""),
ip_address=str(row.ip_address or ""),
port=int(row.port or 22),
protocol=str(row.protocol or "ssh"),
username=str(row.username or ""),
has_password=bool(str(row.password_enc or "").strip()),
connect_status=status, # type: ignore[arg-type]
connect_message=str(row.connect_message or "")[:500],
connect_detail=str(row.connect_detail or "")[:8000],
connect_tested_at=row.connect_tested_at,
tags=str(row.tags or ""),
remark=str(row.remark or ""),
source=str(row.source or ""),
source_ref=str(row.source_ref or ""),
exec_policy=normalize_exec_policy(getattr(row, "exec_policy", None)), # type: ignore[arg-type]
hop_enabled=bool(row.hop_enabled),
hop_vendor=str(row.hop_vendor or "zte"),
hop_host=str(row.hop_host or ""),
hop_port=int(row.hop_port or 22),
hop_protocol=str(row.hop_protocol or "ssh"),
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"),
hop_enter_system_view=bool(getattr(row, "hop_enter_system_view", False)),
created_at=row.created_at,
updated_at=row.updated_at,
)
def get_device_credentials(row: ManagedNE) -> dict[str, Any]:
hop_enabled = bool(row.hop_enabled)
hop_password = ""
if hop_enabled and str(row.hop_password_enc or "").strip():
hop_password = decrypt_secret(row.hop_password_enc)
return {
"id": str(row.id),
"vendor": str(row.vendor or ""),
"device_type": str(row.device_type or ""),
"ip_address": str(row.ip_address or ""),
"port": int(row.port or 22),
"protocol": str(row.protocol or "ssh"),
"username": str(row.username or ""),
"password": decrypt_secret(row.password_enc),
"enable_secret": decrypt_secret(row.enable_secret_enc),
"name": str(row.name or ""),
"exec_policy": normalize_exec_policy(getattr(row, "exec_policy", None)),
"hop_enabled": hop_enabled,
"hop_vendor": str(row.hop_vendor or "zte"),
"hop_host": str(row.hop_host or ""),
"hop_port": int(row.hop_port or 22),
"hop_protocol": str(row.hop_protocol or "ssh"),
"hop_username": str(row.hop_username or ""),
"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"),
"hop_enter_system_view": bool(getattr(row, "hop_enter_system_view", False)),
}