netx/netx_api/managed_ne_router.py
oliver 8bdaaaacbf Add NE capability descriptors and native async exec jobs for agents.
Expose recommended_mode/hints on getManagedNe, support background exec-jobs with getNeExecJob in MCP, and allow linux_shell on MikroTik as well as Linux.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 18:32:14 +08:00

387 lines
12 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_capability import build_ne_capability
from .ne_exec import execute_managed_ne_commands, execute_managed_ne_commands_batch
from .ne_exec_jobs import get_ne_exec_job, start_ne_exec_job
from .ne_schemas import (
BatchAccountApplyRequest,
BatchHopApplyRequest,
ConnectTestRequest,
ManagedNeCreate,
ManagedNeExecBatchRequest,
ManagedNeExecJobCreate,
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 .config import settings
from .device_types import WEBCRT_DEVICE_TYPES
return {
"device_types": list(WEBCRT_DEVICE_TYPES),
"vendors": list(SUPPORTED_VENDORS),
"exec_policy_enabled": bool(getattr(settings, "ne_exec_policy_enabled", False)),
}
@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()
def _audit_exec(
db: Session,
*,
uid: str,
uname: str,
path: str,
out: dict,
body_ne_id: str = "",
body_ume_ne_id: str = "",
commands: list | None = None,
) -> None:
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=path,
status_code=200 if out.get("ok") else 502,
detail={
"ne_id": body_ne_id,
"ume_ne_id": body_ume_ne_id,
"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 commands or [])[:20],
"ok": bool(out.get("ok")),
"error": str(out.get("error") or "")[:500],
"output_len": len(str(out.get("output") or "")),
},
)
@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 CLI (policy from managed NE exec_policy)."""
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,
)
_audit_exec(
db,
uid=uid,
uname=uname,
path="/v1/managed-ne/exec",
out=out,
body_ne_id=body.ne_id or "",
body_ume_ne_id=body.ume_ne_id or "",
commands=list(body.commands 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("/exec-jobs")
def api_create_exec_job(
body: ManagedNeExecJobCreate,
ctx: Annotated[AuthContext, Depends(require_user)],
db: Session = Depends(get_db),
):
"""Start a background exec (single or batch). Poll GET /exec-jobs/{job_id}."""
from .db import SessionLocal
uid, uname = _actor(ctx)
payload = body.model_dump(exclude_none=True)
is_batch = bool(
payload.get("targets")
or payload.get("ne_ids")
or payload.get("ume_ne_ids")
)
kind = "exec-batch" if is_batch else "exec"
def _runner() -> dict:
with SessionLocal() as job_db:
if is_batch:
targets = payload.get("targets")
out = execute_managed_ne_commands_batch(
targets=targets,
ne_ids=payload.get("ne_ids"),
ume_ne_ids=payload.get("ume_ne_ids"),
commands=payload.get("commands"),
read_timeout_sec=payload.get("read_timeout_sec"),
concurrency=payload.get("concurrency"),
)
write_audit(
job_db,
action="ne.exec_batch",
actor_user_id=uid,
actor_username=uname,
method="POST",
path="/v1/managed-ne/exec-jobs",
status_code=200,
detail={
"async": True,
"ne_ids": list(payload.get("ne_ids") or [])[:100],
"ume_ne_ids": list(payload.get("ume_ne_ids") or [])[:100],
"ok": bool(out.get("ok", True)),
},
)
return out
cmds = list(payload.get("commands") or [])
out = execute_managed_ne_commands(
job_db,
cmds,
ne_id=payload.get("ne_id"),
ume_ne_id=payload.get("ume_ne_id"),
read_timeout_sec=payload.get("read_timeout_sec"),
)
_audit_exec(
job_db,
uid=uid,
uname=uname,
path="/v1/managed-ne/exec-jobs",
out=out,
body_ne_id=str(payload.get("ne_id") or ""),
body_ume_ne_id=str(payload.get("ume_ne_id") or ""),
commands=cmds,
)
return out
ack = start_ne_exec_job(kind=kind, arguments=payload, runner=_runner)
write_audit(
db,
action="ne.exec_job_start",
actor_user_id=uid,
actor_username=uname,
method="POST",
path="/v1/managed-ne/exec-jobs",
status_code=200 if ack.get("ok") else 429,
detail={"job_id": ack.get("job_id") or "", "kind": kind, "ok": bool(ack.get("ok"))},
)
return ack
@router.get("/exec-jobs/{job_id}")
def api_get_exec_job(
job_id: str,
ctx: Annotated[AuthContext, Depends(require_user)],
):
"""Poll a background exec job started via POST /exec-jobs."""
_ = ctx
return get_ne_exec_job(job_id)
@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)):
out = get_managed_ne(db, ne_id).model_dump()
out["capability"] = build_ne_capability(
device_type=out.get("device_type"),
exec_policy=out.get("exec_policy"),
vendor=out.get("vendor"),
hop_enabled=bool(out.get("hop_enabled")),
)
return out
@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)