diff --git a/app/routers/planning_import.py b/app/routers/planning_import.py index 584106c..cd25034 100644 --- a/app/routers/planning_import.py +++ b/app/routers/planning_import.py @@ -794,6 +794,68 @@ async def iexec_yum_dryrun(request: Request, row_id: int, db=Depends(get_db)): return JSONResponse(result) +@router.post("/patching/iexec/pre-capture/{row_id}") +async def iexec_pre_capture(request: Request, row_id: int, db=Depends(get_db)): + """Step 3b — capture services + ports avant patch (wiki SANEF).""" + user = get_current_user(request) + if not user: + return JSONResponse({"ok": False, "detail": "Non authentifié"}, status_code=401) + perms = get_user_perms(db, user) + row, err = _common_iexec_row_check(row_id, db, user, perms) + if err: + return err + hostname = (row.hostname or row.asset_name).strip() + + from ..services.patch_run_service import pre_patch_capture + result = pre_patch_capture(hostname) + + try: + db.execute(text(""" + INSERT INTO patch_planning_row_log (row_id, action, details, performed_by) + VALUES (:rid, 'pre_patch_capture', :de, :uid) + """), {"rid": row_id, + "de": json.dumps({k: v for k, v in result.items() if k != "stdout"}, + ensure_ascii=False), + "uid": user.get("uid")}) + db.commit() + except Exception as e: + print(f"[iexec_pre_capture] audit log failed: {e}") + + result["row_id"] = row_id + return JSONResponse(result) + + +@router.post("/patching/iexec/post-compare/{row_id}") +async def iexec_post_compare(request: Request, row_id: int, db=Depends(get_db)): + """Step 3d — compare services+ports avant/après patch + rapport (wiki SANEF).""" + user = get_current_user(request) + if not user: + return JSONResponse({"ok": False, "detail": "Non authentifié"}, status_code=401) + perms = get_user_perms(db, user) + row, err = _common_iexec_row_check(row_id, db, user, perms) + if err: + return err + hostname = (row.hostname or row.asset_name).strip() + + from ..services.patch_run_service import post_patch_compare + result = post_patch_compare(hostname) + + try: + db.execute(text(""" + INSERT INTO patch_planning_row_log (row_id, action, details, performed_by) + VALUES (:rid, 'post_patch_compare', :de, :uid) + """), {"rid": row_id, + "de": json.dumps({k: v for k, v in result.items() if k != "stdout"}, + ensure_ascii=False), + "uid": user.get("uid")}) + db.commit() + except Exception as e: + print(f"[iexec_post_compare] audit log failed: {e}") + + result["row_id"] = row_id + return JSONResponse(result) + + @router.post("/patching/iexec/yum-update/{row_id}") async def iexec_yum_update(request: Request, row_id: int, db=Depends(get_db)): """Step 3 — vrai patch : `sudo -n yum update -y --exclude=...`.""" diff --git a/app/services/patch_run_service.py b/app/services/patch_run_service.py index 5e8efee..3f81786 100644 --- a/app/services/patch_run_service.py +++ b/app/services/patch_run_service.py @@ -1,10 +1,11 @@ """Service exécution patch yum : - - yum_dryrun() : `sudo -n yum update --assumeno --exclude=...` - - yum_update() : `sudo -n yum update -y --exclude=...` - Gère la résolution DNS, ouverture SSH (réutilise _resolve/_connect du - realtime_audit_service), validation des excludes (anti-injection shell), - capture de la sortie + heuristique de résumé du plan yum. + - pre_patch_capture() : capture services + ports avant patch (wiki SANEF) + - yum_dryrun() : `sudo -n yum update --assumeno --exclude=...` + - yum_update() : `sudo -n yum update -y --exclude=...` + - post_patch_compare() : compare services/ports avant/après + rapport + Réutilise _resolve/_connect du realtime_audit_service. """ +import base64 import logging import re from typing import Dict, Any, List @@ -136,3 +137,176 @@ def yum_update(hostname: str, excludes_raw) -> Dict[str, Any]: "stdout_tail": r["stdout"][-5000:], "stderr": r["stderr"][:500] if r["stderr"] else "", } + + +# ─── B3.4 + B3.5 — Pre/Post patch capture (scripts du wiki SANEF) ───────── + +PRE_PATCH_SCRIPT = r"""#!/bin/bash +# Généré par PatchCenter — capture pré-patching (wiki SANEF "Patch Linux") +HOSTNAME=$(hostname) +SNAPSHOT_DIR="/tmp" +sudo -n systemctl list-units --type=service --state=running --no-pager \ + | awk '{print $1}' | grep '\.service$' \ + > ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt +SVC=$(wc -l < ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt) +echo "OK services_avant=${SVC} -> ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt" +sudo -n ss -tlnup > ${SNAPSHOT_DIR}/secops_ports_avant_${HOSTNAME}.txt +sudo -n ss -tlnup | awk 'NR>1 && $7 != "" { + match($7, /users:\(\("([^"]+)"/, arr) + split($5, addr, ":") + port = addr[length(addr)] + if (arr[1] != "" && port+0 < 32768) print port, arr[1] +}' | sort -u > ${SNAPSHOT_DIR}/secops_ports_detail_avant_${HOSTNAME}.txt +PORTS=$(grep -c LISTEN ${SNAPSHOT_DIR}/secops_ports_avant_${HOSTNAME}.txt || echo 0) +echo "OK ports_avant=${PORTS} -> ${SNAPSHOT_DIR}/secops_ports_avant_${HOSTNAME}.txt" +""" + +POST_PATCH_SCRIPT = r"""#!/bin/bash +# Généré par PatchCenter — comparaison post-patching (wiki SANEF "Patch Linux") +HOSTNAME=$(hostname) +SNAPSHOT_DIR="/tmp" +RAPPORT="/tmp/rapport_patching_${HOSTNAME}_$(date +%Y%m%d_%H%M).txt" +sudo -n systemctl list-units --type=service --state=running --no-pager \ + | awk '{print $1}' | grep '\.service$' \ + > ${SNAPSHOT_DIR}/secops_services_apres_${HOSTNAME}.txt +DISPARUS_SVC=$(comm -23 \ + <(sort ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt) \ + <(sort ${SNAPSHOT_DIR}/secops_services_apres_${HOSTNAME}.txt) \ + | grep -v "user@") +APPARUS_SVC=$(comm -13 \ + <(sort ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt) \ + <(sort ${SNAPSHOT_DIR}/secops_services_apres_${HOSTNAME}.txt) \ + | grep -Ev "setroubleshootd|user@") +{ +echo "=== Rapport patching ${HOSTNAME} - $(date '+%Y-%m-%d %H:%M') ===" +echo +echo "--- SERVICES ---" +echo "Avant : $(wc -l < ${SNAPSHOT_DIR}/secops_services_avant_${HOSTNAME}.txt) | Après : $(wc -l < ${SNAPSHOT_DIR}/secops_services_apres_${HOSTNAME}.txt)" +if [ -z "$DISPARUS_SVC" ]; then echo "OK_services_disparus=0"; else echo "KO_services_disparus:"; echo "$DISPARUS_SVC"; fi +if [ -z "$APPARUS_SVC" ]; then echo "OK_services_apparus=0"; else echo "WARN_services_apparus:"; echo "$APPARUS_SVC"; fi +} | tee ${RAPPORT} +sudo -n ss -tlnup > ${SNAPSHOT_DIR}/secops_ports_apres_${HOSTNAME}.txt +sudo -n ss -tlnup | awk 'NR>1 && $7 != "" { + match($7, /users:\(\("([^"]+)"/, arr) + split($5, addr, ":") + port = addr[length(addr)] + if (arr[1] != "" && port+0 < 32768) print port, arr[1] +}' | sort -u > ${SNAPSHOT_DIR}/secops_ports_detail_apres_${HOSTNAME}.txt +PORTS_DISPARUS=$(comm -23 \ + <(sort ${SNAPSHOT_DIR}/secops_ports_detail_avant_${HOSTNAME}.txt) \ + <(sort ${SNAPSHOT_DIR}/secops_ports_detail_apres_${HOSTNAME}.txt)) +PORTS_APPARUS=$(comm -13 \ + <(sort ${SNAPSHOT_DIR}/secops_ports_detail_avant_${HOSTNAME}.txt) \ + <(sort ${SNAPSHOT_DIR}/secops_ports_detail_apres_${HOSTNAME}.txt)) +{ +echo +echo "--- PORTS ---" +echo "Avant : $(grep -c LISTEN ${SNAPSHOT_DIR}/secops_ports_avant_${HOSTNAME}.txt) | Après : $(grep -c LISTEN ${SNAPSHOT_DIR}/secops_ports_apres_${HOSTNAME}.txt)" +if [ -z "$PORTS_DISPARUS" ]; then echo "OK_ports_disparus=0"; else echo "KO_ports_disparus:"; echo "$PORTS_DISPARUS"; fi +if [ -z "$PORTS_APPARUS" ]; then echo "OK_ports_apparus=0"; else echo "WARN_ports_apparus:"; echo "$PORTS_APPARUS"; fi +echo +echo "Rapport : ${RAPPORT}" +} | tee -a ${RAPPORT} +""" + + +def _push_and_run(client, remote_path: str, script_content: str, timeout: int) -> Dict[str, Any]: + """Pousse le script (encode base64) puis l'exécute. Retourne {rc, stdout, stderr}.""" + b64 = base64.b64encode(script_content.encode("utf-8")).decode("ascii") + cmd = (f"echo '{b64}' | base64 -d > {remote_path} && " + f"chmod +x {remote_path} && bash {remote_path} 2>&1") + return _exec(client, cmd, timeout) + + +def _parse_capture_report(stdout: str) -> Dict[str, Any]: + """Parse les marqueurs OK_/KO_/WARN_ du script post-patch.""" + parsed = { + "services_disparus": [], "services_apparus": [], + "ports_disparus": [], "ports_apparus": [], + "services_avant": None, "services_apres": None, + "ports_avant": None, "ports_apres": None, + } + section = None # 'svc_dis', 'svc_app', 'port_dis', 'port_app' + for ln in stdout.splitlines(): + s = ln.strip() + if s.startswith("KO_services_disparus"): section = "svc_dis"; continue + if s.startswith("WARN_services_apparus"): section = "svc_app"; continue + if s.startswith("KO_ports_disparus"): section = "port_dis"; continue + if s.startswith("WARN_ports_apparus"): section = "port_app"; continue + if s.startswith("OK_") or s.startswith("---") or not s: + section = None + continue + if section == "svc_dis": parsed["services_disparus"].append(s) + elif section == "svc_app": parsed["services_apparus"].append(s) + elif section == "port_dis": parsed["ports_disparus"].append(s) + elif section == "port_app": parsed["ports_apparus"].append(s) + # Compteurs avant/après depuis lignes "Avant : X | Après : Y" + for ln in stdout.splitlines(): + m = re.match(r"\s*Avant\s*:\s*(\d+).*Apr.s\s*:\s*(\d+)", ln) + if not m: + continue + if parsed["services_avant"] is None: + parsed["services_avant"] = int(m.group(1)) + parsed["services_apres"] = int(m.group(2)) + elif parsed["ports_avant"] is None: + parsed["ports_avant"] = int(m.group(1)) + parsed["ports_apres"] = int(m.group(2)) + return parsed + + +def pre_patch_capture(hostname: str) -> Dict[str, Any]: + """B3.4 — Capture services+ports avant patch (snapshot dans /tmp côté serveur).""" + client, target, err = _open_ssh(hostname) + if err: + return {"ok": False, "detail": err, "target": target} + try: + r = _push_and_run(client, "/tmp/secops_pre_patching.sh", + PRE_PATCH_SCRIPT, timeout=60) + finally: + try: + client.close() + except Exception: + pass + ok = (r["rc"] == 0) + return { + "ok": ok, "rc": r["rc"], "target": target, + "stdout": r["stdout"][-2000:], + "stderr": r["stderr"][:500] if r["stderr"] else "", + } + + +def post_patch_compare(hostname: str) -> Dict[str, Any]: + """B3.5 — Compare services+ports avant/après patch + génère rapport. + À exécuter après le yum update + reboot, le serveur doit avoir + ses fichiers /tmp/secops_*_avant_*.txt déjà présents (= pre_patch_capture).""" + client, target, err = _open_ssh(hostname) + if err: + return {"ok": False, "detail": err, "target": target} + try: + r = _push_and_run(client, "/tmp/secops_post_patching.sh", + POST_PATCH_SCRIPT, timeout=60) + finally: + try: + client.close() + except Exception: + pass + parsed = _parse_capture_report(r["stdout"]) + has_disparus = bool(parsed["services_disparus"]) or bool(parsed["ports_disparus"]) + has_apparus = bool(parsed["services_apparus"]) or bool(parsed["ports_apparus"]) + if r["rc"] != 0: + status = "ko" + elif has_disparus: + status = "ko" + elif has_apparus: + status = "warn" + else: + status = "ok" + return { + "ok": (r["rc"] == 0), + "status": status, + "rc": r["rc"], + "target": target, + "report": parsed, + "stdout": r["stdout"][-3000:], + "stderr": r["stderr"][:500] if r["stderr"] else "", + } diff --git a/app/templates/patching_iexec.html b/app/templates/patching_iexec.html index efa3d77..8a625f5 100644 --- a/app/templates/patching_iexec.html +++ b/app/templates/patching_iexec.html @@ -51,7 +51,9 @@ Verdict Snapshot Dry-run + Pre-capt. Patch + Post-cmp. @@ -70,10 +72,12 @@ en attente · · + · · + · {% else %} - Aucune ligne éligible. + Aucune ligne éligible. {% endfor %} @@ -92,10 +96,16 @@ → Step 2 (snapshot vCenter) + + @@ -105,7 +115,9 @@ const btnRun = document.getElementById('btn-run-all'); const btnStep2 = document.getElementById('btn-step2'); const btnDryrun = document.getElementById('btn-dryrun'); + const btnPre = document.getElementById('btn-pre'); const btnStep3 = document.getElementById('btn-step3'); + const btnPost = document.getElementById('btn-post'); const tbody = document.getElementById('check-tbody'); const summary = document.getElementById('run-summary'); const detailsCard = document.getElementById('details-card'); @@ -190,12 +202,16 @@ function refreshStepButtons(){ const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]')); - const ckOk = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok'); + const ckOk = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok'); const snapOk = trs.filter(tr => tr._snapData && tr._snapData.ok); - const dryOk = trs.filter(tr => tr._dryData && tr._dryData.ok); - btnStep2.disabled = (ckOk.length === 0); + const dryOk = trs.filter(tr => tr._dryData && tr._dryData.ok); + const preOk = trs.filter(tr => tr._preData && tr._preData.ok); + const patchOk= trs.filter(tr => tr._patchData && tr._patchData.ok); + btnStep2.disabled = (ckOk.length === 0); btnDryrun.disabled = (snapOk.length === 0); - btnStep3.disabled = (dryOk.length === 0); + btnPre.disabled = (dryOk.length === 0); + btnStep3.disabled = (preOk.length === 0); + btnPost.disabled = (patchOk.length === 0); } btnStep2.addEventListener('click', async () => { @@ -265,11 +281,74 @@ refreshStepButtons(); }); - btnStep3.addEventListener('click', async () => { + btnPre.addEventListener('click', async () => { const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]')); const targets = trs.filter(tr => tr._dryData && tr._dryData.ok); if (!targets.length) { alert('Aucun serveur avec dry-run OK'); return; } - if (!confirm('⚠ ATTENTION ⚠\n\nLancer yum update -y (PATCH RÉEL) sur ' + targets.length + ' serveur(s) ?\nCes serveurs vont être modifiés. Snapshot pris en amont.')) return; + if (!confirm('Capture services+ports avant patch sur ' + targets.length + ' serveur(s) ?')) return; + btnPre.disabled = true; btnStep3.disabled = true; + let okCount = 0, koCount = 0; + for (const tr of targets) { + const cell = tr.querySelector('.cell-pre'); + cell.innerHTML = '… capture'; + try { + const r = await fetch('/patching/iexec/pre-capture/' + tr.dataset.rowId, {method:'POST'}); + const j = await r.json(); + tr._preData = j; + if (j.ok) { + okCount++; + cell.innerHTML = '✓ snapshot'; + } else { + koCount++; + cell.innerHTML = '✗ ' + escapeHTML((j.detail||'KO').slice(0,80)) + ''; + } + } catch(e) { + koCount++; + cell.innerHTML = '✗ erreur'; + } + } + summary.innerHTML += ' · Pre-capt : ✓ ' + okCount + ' / ✗ ' + koCount; + refreshStepButtons(); + }); + + btnPost.addEventListener('click', async () => { + const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]')); + const targets = trs.filter(tr => tr._patchData && tr._patchData.ok); + if (!targets.length) { alert('Aucun serveur avec patch OK'); return; } + if (!confirm('Comparer services+ports avant/après patch sur ' + targets.length + ' serveur(s) ?\n(à lancer après le reboot du serveur)')) return; + btnPost.disabled = true; + let okCount = 0, warnCount = 0, koCount = 0; + for (const tr of targets) { + const cell = tr.querySelector('.cell-post'); + cell.innerHTML = '… compare'; + try { + const r = await fetch('/patching/iexec/post-compare/' + tr.dataset.rowId, {method:'POST'}); + const j = await r.json(); + tr._postData = j; + const st = j.status || (j.ok ? 'ok' : 'ko'); + const rep = j.report || {}; + const dispSvc = (rep.services_disparus||[]).length; + const appSvc = (rep.services_apparus||[]).length; + const dispPort = (rep.ports_disparus||[]).length; + const appPort = (rep.ports_apparus||[]).length; + const summ = 'svc -' + dispSvc + ' +' + appSvc + ' / port -' + dispPort + ' +' + appPort; + if (st === 'ok') { okCount++; cell.innerHTML = '✓ ' + escapeHTML(summ) + ''; } + else if (st === 'warn') { warnCount++; cell.innerHTML = '⚠ ' + escapeHTML(summ) + ''; } + else { koCount++; cell.innerHTML = '✗ ' + escapeHTML(summ) + ''; } + } catch(e) { + koCount++; + cell.innerHTML = '✗ erreur'; + } + } + summary.innerHTML += ' · Post-cmp : ✓ ' + okCount + ' · ⚠ ' + warnCount + ' · ✗ ' + koCount; + refreshStepButtons(); + }); + + btnStep3.addEventListener('click', async () => { + const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]')); + const targets = trs.filter(tr => tr._preData && tr._preData.ok); + if (!targets.length) { alert('Aucun serveur avec pre-capture OK'); return; } + if (!confirm('⚠ ATTENTION ⚠\n\nLancer yum update -y (PATCH RÉEL) sur ' + targets.length + ' serveur(s) ?\nSnapshot + pre-capture déjà faits.')) return; if (!confirm('Confirmer une 2e fois : patcher RÉELLEMENT ' + targets.length + ' serveur(s) ?')) return; btnStep3.disabled = true; btnDryrun.disabled = true; btnStep2.disabled = true; let okCount = 0, koCount = 0;