mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 02:33:23 +08:00
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>
This commit is contained in:
parent
3c9e0efd2b
commit
312f6f2f96
25 changed files with 1484 additions and 2016 deletions
|
|
@ -42,6 +42,7 @@ from .ne_service import (
|
|||
update_managed_ne,
|
||||
)
|
||||
from .models import ManagedNE
|
||||
from .table_filters import TableFilterParams, table_filter_params
|
||||
|
||||
router = APIRouter(prefix="/v1/managed-ne", tags=["managed-ne"])
|
||||
|
||||
|
|
@ -63,6 +64,7 @@ def api_list_managed_ne(
|
|||
connect_status: str | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=500),
|
||||
table_filters: TableFilterParams = Depends(table_filter_params),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return list_managed_ne(
|
||||
|
|
@ -72,6 +74,7 @@ def api_list_managed_ne(
|
|||
connect_status=connect_status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
table_filters=table_filters,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class ManagedNeCreate(BaseModel):
|
|||
vendor: str
|
||||
device_type: str
|
||||
ip_address: str
|
||||
port: int = 22
|
||||
port: int = Field(default=22, ge=1, le=65535)
|
||||
protocol: str = "ssh"
|
||||
username: str
|
||||
password: str = ""
|
||||
|
|
@ -27,7 +27,7 @@ class ManagedNeCreate(BaseModel):
|
|||
hop_enabled: bool = False
|
||||
hop_vendor: str = "zte"
|
||||
hop_host: str = ""
|
||||
hop_port: int = 22
|
||||
hop_port: int = Field(default=22, ge=1, le=65535)
|
||||
hop_protocol: str = "ssh"
|
||||
hop_username: str = ""
|
||||
hop_password: str = ""
|
||||
|
|
@ -63,7 +63,7 @@ class ManagedNeUpdate(BaseModel):
|
|||
vendor: str | None = None
|
||||
device_type: str | None = None
|
||||
ip_address: str | None = None
|
||||
port: int | None = None
|
||||
port: int | None = Field(default=None, ge=1, le=65535)
|
||||
protocol: str | None = None
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
|
@ -73,7 +73,7 @@ class ManagedNeUpdate(BaseModel):
|
|||
hop_enabled: bool | None = None
|
||||
hop_vendor: str | None = None
|
||||
hop_host: str | None = None
|
||||
hop_port: int | None = None
|
||||
hop_port: int | None = Field(default=None, ge=1, le=65535)
|
||||
hop_protocol: str | None = None
|
||||
hop_username: str | None = None
|
||||
hop_password: str | None = None
|
||||
|
|
@ -194,7 +194,7 @@ class HopProxyConfig(BaseModel):
|
|||
|
||||
hop_vendor: str = "zte"
|
||||
hop_host: str
|
||||
hop_port: int = 22
|
||||
hop_port: int = Field(default=22, ge=1, le=65535)
|
||||
hop_protocol: str = "ssh"
|
||||
hop_username: str
|
||||
hop_password: str = ""
|
||||
|
|
|
|||
|
|
@ -60,6 +60,63 @@ 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"
|
||||
|
|
@ -213,9 +270,8 @@ def _apply_hop_update(row: ManagedNE, data: dict[str, Any]) -> None:
|
|||
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 _normalize_hop_target_auth_mode(row.hop_target_auth_mode) != "bastion_managed"
|
||||
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")
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ from typing import Any
|
|||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy import false, func, literal, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS, WEBCRT_DEVICE_TYPES, is_placeholder_ne_source
|
||||
from .device_types import SUPPORTED_VENDORS, WEBCRT_DEVICE_TYPES, is_placeholder_ne_source
|
||||
from .models import ManagedNE
|
||||
from .ne_crypto import encrypt_secret
|
||||
from .ne_hop_templates import default_bastion_username_template, default_hop_command_template
|
||||
|
|
@ -24,8 +24,11 @@ from .ne_service_common import (
|
|||
_apply_hop_update,
|
||||
_normalize_hop_target_auth_mode,
|
||||
_normalize_hop_vendor,
|
||||
_normalize_ip,
|
||||
_normalize_protocol,
|
||||
_require_mgmt_address,
|
||||
connect_snapshot,
|
||||
hop_password_optional,
|
||||
reset_connect_status_if_changed,
|
||||
_normalize_saved_hop_endpoint,
|
||||
_normalize_vendor,
|
||||
_now,
|
||||
|
|
@ -33,16 +36,43 @@ from .ne_service_common import (
|
|||
_validate_hop_on_create,
|
||||
row_to_out,
|
||||
)
|
||||
from .table_filters import TableFilterParams, filter_query
|
||||
from .ne_exec_guard import (
|
||||
EXEC_POLICY_READONLY,
|
||||
allows_open_exec_policy,
|
||||
require_exec_policy_writable,
|
||||
)
|
||||
|
||||
# Inventory create stays strict; updates must also accept WebCRT/LLDP placeholder types
|
||||
# (generic/linux) so operators can open the form and promote them to zte_zxros etc.
|
||||
# Create and update share the form's device-type list, which includes linux/generic
|
||||
# (Linux hosts and WebCRT/LLDP placeholders promoted to inventory).
|
||||
_ALLOWED_UPDATE_DEVICE_TYPES = frozenset(WEBCRT_DEVICE_TYPES)
|
||||
|
||||
def managed_ne_filter_expressions() -> dict[str, Any]:
|
||||
"""Column filter fields for the NE management table (tags handled separately)."""
|
||||
return {
|
||||
"name": func.coalesce(func.nullif(ManagedNE.name, ""), ManagedNE.ip_address),
|
||||
"vendor": ManagedNE.vendor,
|
||||
"deviceType": ManagedNE.device_type,
|
||||
"source": func.coalesce(func.nullif(ManagedNE.source, ""), "manual"),
|
||||
"ip": ManagedNE.ip_address,
|
||||
"username": ManagedNE.username,
|
||||
"connectStatus": ManagedNE.connect_status,
|
||||
}
|
||||
|
||||
|
||||
def _filter_by_tags(stmt: Any, value: dict[str, Any]) -> Any:
|
||||
"""Tags are space-separated; a row matches when any selected tag is present ("" = untagged)."""
|
||||
tags_col = func.coalesce(ManagedNE.tags, "")
|
||||
padded = literal(" ") + tags_col + literal(" ")
|
||||
conds = []
|
||||
for tag in value["values"]:
|
||||
tag = str(tag).strip()
|
||||
escaped = tag.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
conds.append(func.trim(tags_col) == "" if not tag else padded.like(f"% {escaped} %", escape="\\"))
|
||||
matched = or_(*conds) if conds else false()
|
||||
return stmt.filter(matched if value["mode"] == "include" else ~matched)
|
||||
|
||||
|
||||
def list_managed_ne(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -51,6 +81,7 @@ def list_managed_ne(
|
|||
connect_status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
table_filters: TableFilterParams | None = None,
|
||||
) -> dict[str, Any]:
|
||||
stmt = db.query(ManagedNE)
|
||||
kw = str(keyword or "").strip()
|
||||
|
|
@ -72,6 +103,22 @@ def list_managed_ne(
|
|||
stmt = stmt.filter(ManagedNE.vendor == v)
|
||||
if cs:
|
||||
stmt = stmt.filter(ManagedNE.connect_status == cs)
|
||||
params = table_filters or TableFilterParams()
|
||||
tags_filter = params.filters.get("tags")
|
||||
if tags_filter and params.field != "tags":
|
||||
stmt = _filter_by_tags(stmt, tags_filter)
|
||||
if params.field == "tags":
|
||||
stmt, _ = filter_query(stmt, managed_ne_filter_expressions(), TableFilterParams(
|
||||
{k: v for k, v in params.filters.items() if k != "tags"}))
|
||||
options: set[str] = set()
|
||||
for (tags_val,) in stmt.with_entities(ManagedNE.tags).all():
|
||||
parts = str(tags_val or "").split()
|
||||
options.update(parts if parts else [""])
|
||||
return {"field": "tags", "options": sorted(options)}
|
||||
stmt, options = filter_query(stmt, managed_ne_filter_expressions(), TableFilterParams(
|
||||
{k: v for k, v in params.filters.items() if k != "tags"}, params.field))
|
||||
if options is not None:
|
||||
return options
|
||||
total = int(stmt.count())
|
||||
rows = (
|
||||
stmt.order_by(ManagedNE.updated_at.desc())
|
||||
|
|
@ -97,10 +144,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)
|
||||
ip = _normalize_ip(body.ip_address)
|
||||
if not ip:
|
||||
raise HTTPException(status_code=400, detail="ip_address_required")
|
||||
if body.device_type not in SUPPORTED_DEVICE_TYPES:
|
||||
ip = _require_mgmt_address(body.ip_address)
|
||||
if body.device_type not in _ALLOWED_UPDATE_DEVICE_TYPES:
|
||||
raise HTTPException(status_code=400, detail="unsupported_device_type")
|
||||
existing = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).first()
|
||||
if existing:
|
||||
|
|
@ -140,10 +185,9 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed
|
|||
if not row:
|
||||
raise HTTPException(status_code=404, detail="managed_ne_not_found")
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
before = connect_snapshot(row)
|
||||
if "ip_address" in data:
|
||||
ip = _normalize_ip(data["ip_address"])
|
||||
if not ip:
|
||||
raise HTTPException(status_code=400, detail="ip_address_required")
|
||||
ip = _require_mgmt_address(data["ip_address"])
|
||||
other = db.query(ManagedNE).filter(ManagedNE.ip_address == ip, ManagedNE.id != ne_id).first()
|
||||
if other:
|
||||
raise HTTPException(status_code=400, detail="ip_address_exists")
|
||||
|
|
@ -157,7 +201,7 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed
|
|||
v = str(data["vendor"] or "").strip()
|
||||
row.vendor = v if v in SUPPORTED_VENDORS else "Other"
|
||||
if "name" in data:
|
||||
row.name = str(data["name"] or "").strip()
|
||||
row.name = str(data["name"] or "").strip() or str(row.ip_address or "")
|
||||
if "port" in data and data["port"] is not None:
|
||||
row.port = int(data["port"])
|
||||
if "protocol" in data and data["protocol"] is not None:
|
||||
|
|
@ -198,6 +242,7 @@ def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> Managed
|
|||
if "ip_address" in data and str(row.ip_address or "").strip():
|
||||
if is_placeholder_ne_source(row.source):
|
||||
row.source = ""
|
||||
reset_connect_status_if_changed(row, before)
|
||||
row.updated_at = _now()
|
||||
# Keep linked fabric node identity in sync (name / IP / vendor).
|
||||
if any(k in data for k in ("ip_address", "name", "vendor", "device_type")):
|
||||
|
|
@ -225,8 +270,6 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d
|
|||
if not hop_user:
|
||||
raise HTTPException(status_code=400, detail="hop_username_required")
|
||||
hop_auth_mode = _normalize_hop_target_auth_mode(hop.hop_target_auth_mode)
|
||||
if not hop_pass and hop_auth_mode != "bastion_managed":
|
||||
raise HTTPException(status_code=400, detail="hop_password_required")
|
||||
|
||||
template = str(hop.hop_command_template or "").strip()
|
||||
if hop_vendor == "bastion" and not template:
|
||||
|
|
@ -243,9 +286,15 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d
|
|||
missing = [x for x in ne_ids if x not in found_ids]
|
||||
if missing:
|
||||
raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}")
|
||||
# Blank password keeps each NE's stored hop password; NEs without one cannot hop.
|
||||
if not hop_pass and not hop_password_optional(hop_vendor, hop_auth_mode):
|
||||
lacking = [str(r.name or r.ip_address) for r in rows if not str(r.hop_password_enc or "").strip()]
|
||||
if lacking:
|
||||
raise HTTPException(status_code=400, detail=f"hop_password_required: {','.join(lacking[:5])}")
|
||||
|
||||
now = _now()
|
||||
for row in rows:
|
||||
before = connect_snapshot(row)
|
||||
row.hop_enabled = True
|
||||
row.hop_vendor = hop_vendor
|
||||
row.hop_host = hop_host
|
||||
|
|
@ -258,6 +307,7 @@ def batch_apply_hop_proxy(db: Session, ids: list[str], hop: HopProxyConfig) -> d
|
|||
row.hop_vrf = str(hop.hop_vrf or "").strip()
|
||||
row.hop_target_auth_mode = hop_auth_mode
|
||||
row.hop_enter_system_view = bool(getattr(hop, "hop_enter_system_view", False))
|
||||
reset_connect_status_if_changed(row, before)
|
||||
row.updated_at = now
|
||||
db.commit()
|
||||
return {"ok": True, "updated": len(rows)}
|
||||
|
|
@ -283,10 +333,12 @@ def batch_apply_account(db: Session, ids: list[str], account: BatchAccountConfig
|
|||
raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}")
|
||||
now = _now()
|
||||
for row in rows:
|
||||
before = connect_snapshot(row)
|
||||
if user:
|
||||
row.username = user
|
||||
if pwd:
|
||||
row.password_enc = pwd_enc
|
||||
reset_connect_status_if_changed(row, before)
|
||||
row.updated_at = now
|
||||
db.commit()
|
||||
return {"ok": True, "updated": len(rows)}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from .models import ManagedNE, UmeInventoryNE
|
||||
from .ne_crypto import CredentialCryptoError, encrypt_secret
|
||||
from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS
|
||||
from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS, is_placeholder_ne_source
|
||||
from .ne_schemas import (
|
||||
ImportFailure,
|
||||
ImportResult,
|
||||
|
|
@ -23,6 +23,9 @@ from .ne_service_common import (
|
|||
UME_SYNC_TAG,
|
||||
_import_cell_str,
|
||||
_infer_managed_ne_type_vendor,
|
||||
_is_valid_mgmt_address,
|
||||
connect_snapshot,
|
||||
reset_connect_status_if_changed,
|
||||
_merge_tags,
|
||||
_normalize_ip,
|
||||
_normalize_protocol,
|
||||
|
|
@ -188,25 +191,38 @@ def import_managed_ne(db: Session, content: bytes, filename: str) -> ImportResul
|
|||
failed: list[ImportFailure] = []
|
||||
for idx, row in df.iterrows():
|
||||
row_no = int(idx) + 2
|
||||
# Savepoint per row: a failing row must not leave a half-written NE behind.
|
||||
savepoint = db.begin_nested()
|
||||
try:
|
||||
ip = _normalize_ip(_import_cell_str(row.get("ip", "")))
|
||||
if not ip:
|
||||
failed.append(ImportFailure(row=row_no, reason="ip_required"))
|
||||
savepoint.rollback()
|
||||
continue
|
||||
if not _is_valid_mgmt_address(ip):
|
||||
failed.append(ImportFailure(row=row_no, reason="invalid_ip_address"))
|
||||
savepoint.rollback()
|
||||
continue
|
||||
device_type = _import_cell_str(row.get("device_type", ""))
|
||||
if device_type not in SUPPORTED_DEVICE_TYPES:
|
||||
failed.append(ImportFailure(row=row_no, reason="unsupported_device_type"))
|
||||
savepoint.rollback()
|
||||
continue
|
||||
username = _import_cell_str(row.get("username", ""))
|
||||
password = _import_cell_str(row.get("password", ""))
|
||||
if not username:
|
||||
failed.append(ImportFailure(row=row_no, reason="username_required"))
|
||||
savepoint.rollback()
|
||||
continue
|
||||
port_raw = row.get("port", 22)
|
||||
try:
|
||||
port = int(port_raw)
|
||||
except (TypeError, ValueError):
|
||||
port = 22
|
||||
if not 1 <= port <= 65535:
|
||||
failed.append(ImportFailure(row=row_no, reason="invalid_port"))
|
||||
savepoint.rollback()
|
||||
continue
|
||||
protocol = _normalize_protocol(str(row.get("protocol", "ssh")))
|
||||
display_name = _import_cell_str(row.get("name", "")) or ip
|
||||
vendor_raw = _import_cell_str(row.get("vendor", "")) or "Other"
|
||||
|
|
@ -217,29 +233,43 @@ def import_managed_ne(db: Session, content: bytes, filename: str) -> ImportResul
|
|||
break
|
||||
existing = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).first()
|
||||
now = _now()
|
||||
is_new = existing is None
|
||||
if existing is None:
|
||||
existing = ManagedNE(ip_address=ip, created_at=now)
|
||||
existing = ManagedNE(ip_address=ip, created_at=now, connect_status="unknown")
|
||||
db.add(existing)
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
before = connect_snapshot(existing)
|
||||
existing.name = display_name
|
||||
existing.vendor = vendor
|
||||
existing.device_type = device_type
|
||||
existing.port = port
|
||||
existing.protocol = protocol
|
||||
existing.username = username
|
||||
existing.password_enc = encrypt_secret(password) if password else ""
|
||||
# Blank password on an existing NE keeps the stored one (template row 2 is blank).
|
||||
if password:
|
||||
existing.password_enc = encrypt_secret(password)
|
||||
elif is_new:
|
||||
existing.password_enc = ""
|
||||
tags_val = _import_cell_str(row.get("tags", ""))
|
||||
remark_val = _import_cell_str(row.get("remark", ""))
|
||||
if tags_val:
|
||||
existing.tags = tags_val
|
||||
if remark_val:
|
||||
existing.remark = remark_val
|
||||
if is_placeholder_ne_source(existing.source):
|
||||
existing.source = ""
|
||||
if not is_new:
|
||||
reset_connect_status_if_changed(existing, before)
|
||||
existing.updated_at = now
|
||||
savepoint.commit()
|
||||
if is_new:
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
except CredentialCryptoError as exc:
|
||||
savepoint.rollback()
|
||||
failed.append(ImportFailure(row=row_no, reason=str(exc)))
|
||||
except Exception as exc:
|
||||
savepoint.rollback()
|
||||
failed.append(ImportFailure(row=row_no, reason=str(exc)[:200]))
|
||||
db.commit()
|
||||
return ImportResult(inserted=inserted, updated=updated, failed=failed)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue