mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
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>
259 lines
8.4 KiB
Python
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)
|