feat(patching/iexec B3.6): bouton 3e Reboot manuel (double confirmation, jamais auto) + 3f Wait reconnexion (poll TCP/22 + SSH uptime, timeout 10min) - shutdown -r +1 avec audit log
This commit is contained in:
parent
19d88f2d53
commit
ff95424e03
@ -948,6 +948,57 @@ async def iexec_yum_update(request: Request, row_id: int, db=Depends(get_db)):
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@router.post("/patching/iexec/reboot/{row_id}")
|
||||
async def iexec_reboot(request: Request, row_id: int, db=Depends(get_db)):
|
||||
"""Step 3e — lance reboot avec délai +1 minute.
|
||||
NE JAMAIS appeler en automatique : doit toujours venir d'un clic
|
||||
utilisateur explicite (double confirmation côté UI)."""
|
||||
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 reboot_host
|
||||
result = reboot_host(hostname)
|
||||
|
||||
try:
|
||||
db.execute(text("""
|
||||
INSERT INTO patch_planning_row_log (row_id, action, details, performed_by)
|
||||
VALUES (:rid, 'reboot_initiated', :de, :uid)
|
||||
"""), {"rid": row_id,
|
||||
"de": json.dumps(result, ensure_ascii=False),
|
||||
"uid": user.get("uid")})
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
print(f"[iexec_reboot] audit log failed: {e}")
|
||||
|
||||
result["row_id"] = row_id
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@router.get("/patching/iexec/reboot-status/{row_id}")
|
||||
async def iexec_reboot_status(request: Request, row_id: int, db=Depends(get_db)):
|
||||
"""Step 3e — poll l'état du serveur après reboot (TCP/22 + SSH 'uptime').
|
||||
Appelé en boucle côté frontend (toutes les 10s)."""
|
||||
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 reboot_status
|
||||
result = reboot_status(hostname)
|
||||
result["row_id"] = row_id
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@router.post("/patching/import/{import_id}/delete")
|
||||
async def import_delete(request: Request, import_id: int, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
|
||||
@ -8,6 +8,8 @@
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List
|
||||
|
||||
from .realtime_audit_service import _resolve, _connect, PARAMIKO_OK
|
||||
@ -400,3 +402,69 @@ def post_patch_compare(hostname: str) -> Dict[str, Any]:
|
||||
"stdout": r["stdout"][-3000:],
|
||||
"stderr": r["stderr"][:500] if r["stderr"] else "",
|
||||
}
|
||||
|
||||
|
||||
# ─── B3.6 — Reboot manuel sur clic + polling reconnexion ──────────────────
|
||||
# IMPORTANT : reboot_host() n'est JAMAIS lancé automatiquement.
|
||||
# Toujours déclenché par un clic utilisateur explicite avec double confirmation
|
||||
# côté frontend.
|
||||
|
||||
def reboot_host(hostname: str) -> Dict[str, Any]:
|
||||
"""Lance reboot avec délai +1 min (laisse le temps à SSH de retourner).
|
||||
Appelée uniquement après confirmation explicite côté UI."""
|
||||
client, target, err = _open_ssh(hostname)
|
||||
if err:
|
||||
return {"ok": False, "detail": err, "target": target}
|
||||
try:
|
||||
cmd = "sudo -n shutdown -r +1 'PatchCenter post-patch reboot' 2>&1"
|
||||
r = _exec(client, cmd, timeout=15)
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
ok = (r["rc"] == 0)
|
||||
return {
|
||||
"ok": ok,
|
||||
"rc": r["rc"],
|
||||
"cmd": cmd,
|
||||
"target": target,
|
||||
"started_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"stdout": r["stdout"][:500],
|
||||
"stderr": r["stderr"][:500] if r["stderr"] else "",
|
||||
}
|
||||
|
||||
|
||||
def reboot_status(hostname: str) -> Dict[str, Any]:
|
||||
"""Vérifie si le serveur est revenu : TCP/22 puis SSH 'uptime'.
|
||||
À appeler en boucle (toutes les 10s côté frontend)."""
|
||||
target = _resolve(hostname)
|
||||
if not target:
|
||||
return {"reachable": False, "tcp22": False, "ssh": False,
|
||||
"detail": "DNS résolution impossible", "target": None}
|
||||
# 1. TCP/22
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(3)
|
||||
sock.connect((target, 22))
|
||||
sock.close()
|
||||
except Exception as e:
|
||||
return {"reachable": False, "tcp22": False, "ssh": False,
|
||||
"target": target, "detail": str(e)[:200]}
|
||||
# 2. SSH minimal — uptime
|
||||
if not PARAMIKO_OK:
|
||||
return {"reachable": True, "tcp22": True, "ssh": False, "target": target,
|
||||
"detail": "paramiko absent côté serveur PatchCenter"}
|
||||
client = _connect(target, hostname)
|
||||
if not client:
|
||||
return {"reachable": True, "tcp22": True, "ssh": False, "target": target,
|
||||
"detail": "TCP/22 OK mais SSH KO (probablement encore en boot)"}
|
||||
try:
|
||||
r = _exec(client, "uptime 2>&1", timeout=10)
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
return {"reachable": True, "tcp22": True, "ssh": True, "target": target,
|
||||
"uptime": (r.get("stdout") or "").strip()[:300]}
|
||||
|
||||
@ -53,6 +53,7 @@
|
||||
<th class="text-left p-1">Dry-run</th>
|
||||
<th class="text-left p-1">Pre-capt.</th>
|
||||
<th class="text-left p-1">Patch</th>
|
||||
<th class="text-left p-1">Reconnex.</th>
|
||||
<th class="text-left p-1">Post-cmp.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -74,10 +75,11 @@
|
||||
<td class="p-1 cell-dry text-gray-500">·</td>
|
||||
<td class="p-1 cell-pre text-gray-500">·</td>
|
||||
<td class="p-1 cell-patch text-gray-500">·</td>
|
||||
<td class="p-1 cell-recon text-gray-500">·</td>
|
||||
<td class="p-1 cell-post text-gray-500">·</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="16" class="p-2 text-gray-500">Aucune ligne éligible.</td></tr>
|
||||
<tr><td colspan="17" class="p-2 text-gray-500">Aucune ligne éligible.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -116,8 +118,14 @@
|
||||
<button id="btn-step3" class="btn-sm bg-cyber-blue/20 text-cyber-blue px-4 py-2 text-xs" disabled title="yum update -y : applique réellement les patchs">
|
||||
→ 3c Patcher
|
||||
</button>
|
||||
<button id="btn-post" class="btn-sm bg-cyber-blue/20 text-cyber-blue px-4 py-2 text-xs" disabled title="Compare services/ports avant/après patch (à lancer après reboot)">
|
||||
→ 3d Post-cmp.
|
||||
<button id="btn-reboot" class="btn-sm bg-cyber-red/20 text-cyber-red px-4 py-2 text-xs" disabled title="shutdown -r +1 sur les serveurs patchés (double confirmation)">
|
||||
→ 3e Reboot
|
||||
</button>
|
||||
<button id="btn-recon" class="btn-sm bg-cyber-yellow/20 text-cyber-yellow px-4 py-2 text-xs" disabled title="Polle TCP/22 + SSH jusqu'à reconnexion">
|
||||
→ 3f Wait reconn.
|
||||
</button>
|
||||
<button id="btn-post" class="btn-sm bg-cyber-blue/20 text-cyber-blue px-4 py-2 text-xs" disabled title="Compare services/ports avant/après patch (à lancer après reconnexion)">
|
||||
→ 3g Post-cmp.
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -129,6 +137,8 @@
|
||||
const btnDryrun = document.getElementById('btn-dryrun');
|
||||
const btnPre = document.getElementById('btn-pre');
|
||||
const btnStep3 = document.getElementById('btn-step3');
|
||||
const btnReboot = document.getElementById('btn-reboot');
|
||||
const btnRecon = document.getElementById('btn-recon');
|
||||
const btnPost = document.getElementById('btn-post');
|
||||
const tbody = document.getElementById('check-tbody');
|
||||
const summary = document.getElementById('run-summary');
|
||||
@ -324,11 +334,14 @@
|
||||
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);
|
||||
const recOk = trs.filter(tr => tr._reconData && tr._reconData.ok);
|
||||
btnStep2.disabled = (ckOk.length === 0);
|
||||
btnDryrun.disabled = (snapOk.length === 0);
|
||||
btnPre.disabled = (dryOk.length === 0);
|
||||
btnStep3.disabled = (preOk.length === 0);
|
||||
btnPost.disabled = (patchOk.length === 0);
|
||||
btnReboot.disabled = (patchOk.length === 0);
|
||||
btnRecon.disabled = (patchOk.length === 0);
|
||||
btnPost.disabled = (recOk.length === 0 && patchOk.length === 0);
|
||||
}
|
||||
|
||||
btnStep2.addEventListener('click', async () => {
|
||||
@ -416,6 +429,73 @@
|
||||
refreshStepButtons();
|
||||
});
|
||||
|
||||
btnReboot.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('⚠ REBOOT ⚠\n\nDéclencher `shutdown -r +1` sur ' + targets.length + ' serveur(s) ?\n(le reboot effectif a lieu dans 1 minute)')) return;
|
||||
if (!confirm('Vraiment ? Liste des hôtes :\n' + targets.map(tr => tr.querySelector('td:nth-child(3)').textContent.trim()).join('\n') + '\n\nConfirmer le reboot ?')) return;
|
||||
btnReboot.disabled = true;
|
||||
let okCount = 0, koCount = 0;
|
||||
for (const tr of targets) {
|
||||
const cell = tr.querySelector('.cell-recon');
|
||||
cell.innerHTML = '<span class="text-cyber-yellow">… reboot demandé</span>';
|
||||
try {
|
||||
const r = await fetch('/patching/iexec/reboot/' + tr.dataset.rowId, {method:'POST'});
|
||||
const j = await r.json();
|
||||
tr._rebootData = j;
|
||||
if (j.ok) {
|
||||
okCount++;
|
||||
cell.innerHTML = '<span class="text-cyber-yellow">⏳ reboot dans 1min · ' + escapeHTML(j.started_at||'') + '</span>';
|
||||
} else {
|
||||
koCount++;
|
||||
cell.innerHTML = '<span class="text-cyber-red" title="' + escapeHTML(j.detail||j.stderr||'') + '">✗ ' + escapeHTML((j.detail||'KO').slice(0,80)) + '</span>';
|
||||
}
|
||||
} catch(e) {
|
||||
koCount++;
|
||||
cell.innerHTML = '<span class="text-cyber-red">✗ erreur</span>';
|
||||
}
|
||||
}
|
||||
summary.innerHTML += ' · Reboot : ✓ ' + okCount + ' / ✗ ' + koCount;
|
||||
});
|
||||
|
||||
btnRecon.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('Attendre la reconnexion (TCP/22 + SSH) sur ' + targets.length + ' serveur(s) ?\nPoll toutes les 10s, timeout 10 min par serveur.')) return;
|
||||
btnRecon.disabled = true;
|
||||
const startTs = Date.now();
|
||||
// Pour chaque target, polling indépendant
|
||||
await Promise.all(targets.map(async (tr) => {
|
||||
const cell = tr.querySelector('.cell-recon');
|
||||
const t0 = Date.now();
|
||||
const TIMEOUT_MS = 10 * 60 * 1000; // 10 min
|
||||
const POLL_MS = 10 * 1000;
|
||||
cell.innerHTML = '<span class="text-cyber-yellow">⏳ poll TCP/22…</span>';
|
||||
while (Date.now() - t0 < TIMEOUT_MS) {
|
||||
await new Promise(r => setTimeout(r, POLL_MS));
|
||||
try {
|
||||
const resp = await fetch('/patching/iexec/reboot-status/' + tr.dataset.rowId);
|
||||
const j = await resp.json();
|
||||
if (j.tcp22 && j.ssh) {
|
||||
const dur = Math.round((Date.now() - t0) / 1000);
|
||||
tr._reconData = {ok: true, downtime_s: dur, uptime: j.uptime};
|
||||
cell.innerHTML = '<span class="text-cyber-green">✓ revenu en ' + dur + 's</span>'
|
||||
+ '<br><span class="text-[10px] text-gray-400" title="' + escapeHTML(j.uptime||'') + '">' + escapeHTML((j.uptime||'').slice(0,60)) + '</span>';
|
||||
return;
|
||||
}
|
||||
cell.innerHTML = '<span class="text-cyber-yellow">⏳ '
|
||||
+ (j.tcp22 ? 'TCP/22 OK · SSH KO' : 'pas joignable')
|
||||
+ ' · ' + Math.round((Date.now()-t0)/1000) + 's</span>';
|
||||
} catch(e) { /* ignore, retry */ }
|
||||
}
|
||||
tr._reconData = {ok: false};
|
||||
cell.innerHTML = '<span class="text-cyber-red">✗ timeout 10 min</span>';
|
||||
}));
|
||||
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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user