netx/netx_api/managed_ne_router.py
oliver 8915455b52 Record WebCRT device login/commands and NE exec in operation audit.
Make session lifecycle and typed CLI lines searchable in audit_log with password redaction, and surface summaries plus device filters in the ops UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 21:36:44 +08:00

259 lines
8.4 KiB
Python

from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import Response
from sqlalchemy.orm import Session
from .auth_deps import AuthContext, require_user
from .auth_service import write_audit
from .db import get_db
from .device_types import SUPPORTED_VENDORS
from .ne_connect import schedule_connect_tests
from .ne_crypto import credentials_configured
from .ne_exec import execute_managed_ne_commands, execute_managed_ne_commands_batch
from .ne_schemas import (
BatchAccountApplyRequest,
BatchHopApplyRequest,
ConnectTestRequest,
ManagedNeCreate,
ManagedNeExecBatchRequest,
ManagedNeExecRequest,
ManagedNeUpdate,
)
from .ne_service import (
batch_apply_account,
batch_apply_hop_proxy,
build_managed_ne_import_template,
create_managed_ne,
batch_delete_managed_ne,
delete_ume_synced_managed_ne,
delete_managed_ne,
get_ids_by_tag,
get_managed_ne,
get_managed_ne_stats,
import_managed_ne,
list_managed_ne,
sync_ume_inventory_to_managed_ne,
update_managed_ne,
)
from .models import ManagedNE
router = APIRouter(prefix="/v1/managed-ne", tags=["managed-ne"])
def _actor(ctx: AuthContext | None = None, request: Request | None = None) -> tuple[str, str]:
if ctx is not None and ctx.user is not None:
return str(ctx.user.id or ""), str(ctx.user.username or "")
if request is not None:
user = getattr(request.state, "auth_user", None)
if user:
return str(getattr(user, "id", "") or ""), str(getattr(user, "username", "") or "")
return "", ""
@router.get("")
def api_list_managed_ne(
keyword: str | None = Query(default=None),
vendor: str | None = Query(default=None),
connect_status: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=500),
db: Session = Depends(get_db),
):
return list_managed_ne(
db,
keyword=keyword,
vendor=vendor,
connect_status=connect_status,
page=page,
page_size=page_size,
)
@router.get("/meta/device-types")
def api_device_types():
# Include generic/linux so LLDP/WebCRT placeholders can be edited without a bogus select value.
from .device_types import WEBCRT_DEVICE_TYPES
return {"device_types": list(WEBCRT_DEVICE_TYPES), "vendors": list(SUPPORTED_VENDORS)}
@router.get("/meta/credentials-configured")
def api_credentials_configured():
return {"configured": credentials_configured()}
@router.get("/meta/stats")
def api_managed_ne_stats(db: Session = Depends(get_db)):
return get_managed_ne_stats(db)
@router.get("/meta/ids-by-tag")
def api_ids_by_tag(tag: str | None = Query(default=None), db: Session = Depends(get_db)):
"""Return all NE ids that carry the given tag (or every id when tag is omitted)."""
return {"ids": get_ids_by_tag(db, tag)}
@router.post("")
def api_create_managed_ne(body: ManagedNeCreate, db: Session = Depends(get_db)):
return create_managed_ne(db, body).model_dump()
@router.get("/import/template")
def api_managed_ne_import_template(format: str = Query(default="xlsx")):
filename, payload, media_type = build_managed_ne_import_template(format)
return Response(
content=payload,
media_type=media_type,
headers={"content-disposition": f'attachment; filename="{filename}"'},
)
@router.post("/import")
async def api_import_managed_ne(file: UploadFile = File(...), db: Session = Depends(get_db)):
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="empty_file")
return import_managed_ne(db, content, file.filename or "import.xlsx").model_dump()
@router.post("/batch-hop")
def api_batch_apply_hop(body: BatchHopApplyRequest, db: Session = Depends(get_db)):
return batch_apply_hop_proxy(db, body.ids, body.hop)
@router.post("/batch-account")
def api_batch_apply_account(body: BatchAccountApplyRequest, db: Session = Depends(get_db)):
return batch_apply_account(db, body.ids, body.account)
@router.post("/batch-delete")
def api_batch_delete_managed_ne(body: ConnectTestRequest, db: Session = Depends(get_db)):
return batch_delete_managed_ne(db, body.ids)
@router.post("/ume-sync")
def api_sync_ume_inventory_to_managed_ne(db: Session = Depends(get_db)):
return sync_ume_inventory_to_managed_ne(db).model_dump()
@router.delete("/ume-sync")
def api_delete_ume_synced_managed_ne(db: Session = Depends(get_db)):
return delete_ume_synced_managed_ne(db).model_dump()
@router.post("/exec")
def api_exec_managed_ne(
body: ManagedNeExecRequest,
ctx: Annotated[AuthContext, Depends(require_user)],
db: Session = Depends(get_db),
):
"""Login to a managed NE or UME inventory NE and run read-only CLI (show/display/ping/traceroute)."""
uid, uname = _actor(ctx)
out = execute_managed_ne_commands(
db,
body.commands,
ne_id=body.ne_id,
ume_ne_id=body.ume_ne_id,
read_timeout_sec=body.read_timeout_sec,
)
device = out.get("device") if isinstance(out.get("device"), dict) else {}
write_audit(
db,
action="ne.exec",
actor_user_id=uid,
actor_username=uname,
method="POST",
path="/v1/managed-ne/exec",
status_code=200 if out.get("ok") else 502,
detail={
"ne_id": body.ne_id or "",
"ume_ne_id": body.ume_ne_id or "",
"ne_name": str(device.get("name") or device.get("ne_name") or ""),
"ne_ip": str(device.get("ip_address") or device.get("ip") or device.get("mgmt_ip") or ""),
"commands": list(out.get("commands") or body.commands or [])[:20],
"ok": bool(out.get("ok")),
"error": str(out.get("error") or "")[:500],
"output_len": len(str(out.get("output") or "")),
},
)
return out
@router.post("/exec-batch")
def api_exec_managed_ne_batch(
body: ManagedNeExecBatchRequest,
ctx: Annotated[AuthContext, Depends(require_user)],
db: Session = Depends(get_db),
):
"""Run read-only CLI on many NEs concurrently (field multi-NE sweeps)."""
uid, uname = _actor(ctx)
targets = None
if body.targets:
targets = [t.model_dump() for t in body.targets]
out = execute_managed_ne_commands_batch(
targets=targets,
ne_ids=body.ne_ids,
ume_ne_ids=body.ume_ne_ids,
commands=body.commands,
read_timeout_sec=body.read_timeout_sec,
concurrency=body.concurrency,
)
items = out.get("items") if isinstance(out, dict) else None
ok_n = 0
fail_n = 0
if isinstance(items, list):
for row in items:
if isinstance(row, dict) and row.get("ok"):
ok_n += 1
else:
fail_n += 1
write_audit(
db,
action="ne.exec_batch",
actor_user_id=uid,
actor_username=uname,
method="POST",
path="/v1/managed-ne/exec-batch",
status_code=200,
detail={
"ne_ids": list(body.ne_ids or [])[:100],
"ume_ne_ids": list(body.ume_ne_ids or [])[:100],
"target_count": len(targets or []) + len(body.ne_ids or []) + len(body.ume_ne_ids or []),
"commands": list(body.commands or [])[:20],
"ok_count": ok_n,
"fail_count": fail_n,
},
)
return out
@router.post("/connect-test")
def api_connect_test(body: ConnectTestRequest, db: Session = Depends(get_db)):
ids = [str(x).strip() for x in body.ids if str(x).strip()]
if not ids:
raise HTTPException(status_code=400, detail="ids_required")
rows = db.query(ManagedNE).filter(ManagedNE.id.in_(ids)).all()
found_ids = {str(r.id) for r in rows}
missing = [x for x in ids if x not in found_ids]
if missing:
raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}")
submitted = schedule_connect_tests(ids)
return {"ok": True, "submitted": submitted}
@router.get("/{ne_id}")
def api_get_managed_ne(ne_id: str, db: Session = Depends(get_db)):
return get_managed_ne(db, ne_id).model_dump()
@router.patch("/{ne_id}")
def api_update_managed_ne(ne_id: str, body: ManagedNeUpdate, db: Session = Depends(get_db)):
return update_managed_ne(db, ne_id, body).model_dump()
@router.delete("/{ne_id}")
def api_delete_managed_ne(ne_id: str, db: Session = Depends(get_db)):
return delete_managed_ne(db, ne_id)