diff --git a/app/routers/audit.py b/app/routers/audit.py index 08d8407..2f65e06 100644 --- a/app/routers/audit.py +++ b/app/routers/audit.py @@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse from fastapi.templating import Jinja2Templates from sqlalchemy import text from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, can_admin, base_context -from ..services.realtime_audit_service import audit_servers_list, save_audit_to_db +from ..services.realtime_audit_service import audit_servers_list, save_audit_to_db, start_audit_job, get_audit_job from ..config import APP_NAME router = APIRouter() @@ -134,7 +134,7 @@ async def audit_global(request: Request, db=Depends(get_db)): parallel = int(form.get("parallel", "5")) # Construire la requete - where = ["s.os_family = 'linux'", "s.etat = 'en_production'"] + where = ["s.os_family = 'linux'", "s.etat = 'production'"] params = {} if exclude_domains: where.append("d.code NOT IN :ed") @@ -173,18 +173,9 @@ async def audit_global(request: Request, db=Depends(get_db)): if not hostnames: return RedirectResponse(url="/audit?msg=no_hosts", status_code=303) - # Lancer l'audit - results = audit_servers_list(hostnames) - request.app.state.last_audit_results = results - - ctx = base_context(request, db, user) - ctx.update({ - "app_name": APP_NAME, "results": results, - "total": len(results), - "ok": sum(1 for r in results if r.get("status") == "OK"), - "failed": sum(1 for r in results if r.get("status") != "OK"), - }) - return templates.TemplateResponse("audit_realtime_results.html", ctx) + # Lancer en arrière-plan + job_id = start_audit_job(hostnames) + return RedirectResponse(url=f"/audit/realtime/progress/{job_id}", status_code=303) @router.post("/audit/realtime", response_class=HTMLResponse) @@ -209,23 +200,57 @@ async def audit_realtime(request: Request, db=Depends(get_db), if not hostnames: return RedirectResponse(url="/audit?msg=no_hosts", status_code=303) - # Lancer l'audit - results = audit_servers_list(hostnames) + # Lancer en arrière-plan + job_id = start_audit_job(hostnames) - # Stocker en session (request.state) pour export/save - request.app.state.last_audit_results = results + return RedirectResponse(url=f"/audit/realtime/progress/{job_id}", status_code=303) + + +@router.get("/audit/realtime/progress/{job_id}", response_class=HTMLResponse) +async def audit_realtime_progress(request: Request, job_id: str, db=Depends(get_db)): + user = get_current_user(request) + if not user: + return RedirectResponse(url="/login") + perms = get_user_perms(db, user) + if not can_view(perms, "audit"): + return RedirectResponse(url="/audit") + + job = get_audit_job(job_id) + if not job: + return RedirectResponse(url="/audit?msg=job_not_found", status_code=303) ctx = base_context(request, db, user) - ctx.update({ - "app_name": APP_NAME, "results": results, - "total": len(results), - "ok": sum(1 for r in results if r.get("status") == "OK"), - "failed": sum(1 for r in results if r.get("status") != "OK"), + ctx.update({"app_name": APP_NAME, "job_id": job_id, "total": job["total"]}) + return templates.TemplateResponse("audit_realtime_progress.html", ctx) + + +@router.get("/audit/realtime/status/{job_id}") +async def audit_realtime_status(request: Request, job_id: str, db=Depends(get_db)): + from fastapi.responses import JSONResponse + user = get_current_user(request) + if not user: + return JSONResponse({"ok": False}, status_code=401) + + job = get_audit_job(job_id) + if not job: + return JSONResponse({"ok": False, "msg": "Job introuvable"}, status_code=404) + + import time + elapsed = int(time.time() - job["started_at"]) + + # When finished, store results in app state for save/export + if job["finished"]: + request.app.state.last_audit_results = job["results"] + + return JSONResponse({ + "ok": True, + "job_id": job_id, + "total": job["total"], + "done": job["done"], + "finished": job["finished"], + "elapsed": elapsed, + "servers": job["servers"], }) - response = templates.TemplateResponse("audit_realtime_results.html", ctx) - response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" - response.headers["Pragma"] = "no-cache" - return response @router.post("/audit/realtime/save") diff --git a/app/services/realtime_audit_service.py b/app/services/realtime_audit_service.py index ab77819..fe0a0f0 100644 --- a/app/services/realtime_audit_service.py +++ b/app/services/realtime_audit_service.py @@ -32,7 +32,7 @@ def _get_ssh_settings(): # Commandes d'audit (simplifiees pour le temps reel) AUDIT_CMDS = { - "os_release": "cat /etc/redhat-release 2>/dev/null || head -1 /etc/os-release 2>/dev/null", + "os_release": "cat /etc/redhat-release 2>/dev/null || grep '^PRETTY_NAME=' /etc/os-release 2>/dev/null | cut -d'\"' -f2", "kernel": "uname -r", "uptime": "uptime -p 2>/dev/null || uptime", "selinux": "getenforce 2>/dev/null || echo N/A", @@ -193,6 +193,124 @@ def audit_servers_list(hostnames): return results +# ═══════════════════════════════════════════════ +# Background audit job manager +# ═══════════════════════════════════════════════ +import threading +import uuid +import time as _time + +_audit_jobs = {} + + +def start_audit_job(hostnames): + """Lance un audit en arriere-plan. Retourne le job_id.""" + job_id = str(uuid.uuid4())[:8] + job = { + "id": job_id, + "started_at": _time.time(), + "total": len(hostnames), + "done": 0, + "servers": {}, + "results": [], + "finished": False, + } + for hn in hostnames: + job["servers"][hn] = {"hostname": hn, "stage": "pending", "detail": "En attente", "status": None} + _audit_jobs[job_id] = job + + def _run(): + threads = [] + for hn in hostnames: + t = threading.Thread(target=_audit_one, args=(job, hn.strip()), daemon=True) + threads.append(t) + t.start() + for t in threads: + t.join() + job["finished"] = True + job["finished_at"] = _time.time() + + threading.Thread(target=_run, daemon=True).start() + return job_id + + +def _audit_one(job, hostname): + job["servers"][hostname]["stage"] = "resolving" + job["servers"][hostname]["detail"] = "Résolution DNS" + + target = _resolve(hostname) + if not target: + job["servers"][hostname]["stage"] = "failed" + job["servers"][hostname]["detail"] = "DNS: aucun suffixe résolu" + job["servers"][hostname]["status"] = "CONNECTION_FAILED" + result = {"hostname": hostname, "status": "CONNECTION_FAILED", + "connection_method": f"DNS: aucun suffixe résolu ({hostname})", "resolved_fqdn": None} + job["results"].append(result) + job["done"] += 1 + return + + job["servers"][hostname]["stage"] = "connecting" + job["servers"][hostname]["detail"] = f"Connexion SSH → {target}" + + client = _connect(target) + if not client: + job["servers"][hostname]["stage"] = "failed" + job["servers"][hostname]["detail"] = f"SSH refusé ({target})" + job["servers"][hostname]["status"] = "CONNECTION_FAILED" + result = {"hostname": hostname, "status": "CONNECTION_FAILED", + "connection_method": f"SSH: connexion refusée ({target})", "resolved_fqdn": target} + job["results"].append(result) + job["done"] += 1 + return + + job["servers"][hostname]["stage"] = "auditing" + job["servers"][hostname]["detail"] = "Collecte des données" + + result = {"hostname": hostname, "status": "OK", "resolved_fqdn": target, + "audit_date": datetime.now().strftime("%Y-%m-%d %H:%M")} + ssh_key, ssh_user = _get_ssh_settings() + result["connection_method"] = f"ssh_key ({ssh_user}@{target})" + + for key, cmd in AUDIT_CMDS.items(): + result[key] = _run(client, cmd) + + try: + client.close() + except Exception: + pass + + # Post-traitement + agents = result.get("agents", "") + result["qualys_active"] = "qualys" in agents and "active" in agents + result["sentinelone_active"] = "sentinelone" in agents and "active" in agents + result["disk_alert"] = False + for line in (result.get("disk_space") or "").split("\n"): + parts = line.split() + pcts = [p for p in parts if "%" in p] + if pcts: + try: + pct = int(pcts[0].replace("%", "")) + if pct >= 90: + result["disk_alert"] = True + except ValueError: + pass + + job["servers"][hostname]["stage"] = "success" + job["servers"][hostname]["detail"] = result.get("os_release", "OK") + job["servers"][hostname]["status"] = "OK" + job["results"].append(result) + job["done"] += 1 + + +def get_audit_job(job_id): + return _audit_jobs.get(job_id) + + +def list_audit_jobs(): + now = _time.time() + return {jid: j for jid, j in _audit_jobs.items() if now - j["started_at"] < 3600} + + def save_audit_to_db(db, results): """Sauvegarde/met a jour les resultats d'audit en base""" updated = 0 @@ -274,9 +392,10 @@ def save_audit_to_db(db, results): except Exception: pass + from .itop_service import _normalize_os_for_itop updates = {} if r.get("os_release"): - updates["os_version"] = r["os_release"].strip() + updates["os_version"] = _normalize_os_for_itop(r["os_release"].strip()) if ip_addr: updates["fqdn"] = resolved diff --git a/app/templates/audit_realtime_progress.html b/app/templates/audit_realtime_progress.html new file mode 100644 index 0000000..e4e38fa --- /dev/null +++ b/app/templates/audit_realtime_progress.html @@ -0,0 +1,126 @@ +{% extends 'base.html' %} +{% block title %}Audit en cours{% endblock %} +{% block content %} +
| Hostname | +Étape | +Détail | +
|---|