feat(patching/iexec B3): step 3a dry-run (yum update --assumeno) + step 3b real patch (yum update -y) avec excludes effectifs depuis v_servers, validation anti-injection sur excludes, log audit, double confirmation pour patch reel
This commit is contained in:
parent
37e6f0d8f3
commit
6c92c71d17
@ -738,6 +738,93 @@ async def iexec_snapshot(request: Request, row_id: int, db=Depends(get_db)):
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
def _common_iexec_row_check(row_id: int, db, user, perms):
|
||||
"""Charge la row et applique les vérifs communes (éligible + Linux + hostname).
|
||||
Retourne (row_obj, error_response). error_response is None si OK."""
|
||||
if not _can_import(perms):
|
||||
return None, JSONResponse({"ok": False, "detail": "Permission refusée"}, status_code=403)
|
||||
row = db.execute(text("""
|
||||
SELECT r.id, r.asset_name, r.os, r.is_eligible,
|
||||
s.hostname, vs.effective_excludes
|
||||
FROM patch_planning_import_rows r
|
||||
LEFT JOIN servers s ON s.id = r.server_id
|
||||
LEFT JOIN v_servers vs ON vs.id = r.server_id
|
||||
WHERE r.id = :id
|
||||
"""), {"id": row_id}).fetchone()
|
||||
if not row:
|
||||
return None, JSONResponse({"ok": False, "detail": "Ligne introuvable"}, status_code=404)
|
||||
if not row.is_eligible:
|
||||
return None, JSONResponse({"ok": False, "detail": "Ligne non éligible"}, status_code=400)
|
||||
os_str = str(row.os or "").lower()
|
||||
if "windows" in os_str:
|
||||
return None, JSONResponse({"ok": False, "detail": "OS Windows non géré"}, status_code=400)
|
||||
if not (row.hostname or row.asset_name):
|
||||
return None, JSONResponse({"ok": False, "detail": "Pas de hostname"}, status_code=400)
|
||||
return row, None
|
||||
|
||||
|
||||
@router.post("/patching/iexec/yum-dryrun/{row_id}")
|
||||
async def iexec_yum_dryrun(request: Request, row_id: int, db=Depends(get_db)):
|
||||
"""Step 3 — pré-vol : `sudo -n yum update --assumeno --exclude=...`."""
|
||||
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 yum_dryrun
|
||||
result = yum_dryrun(hostname, row.effective_excludes)
|
||||
|
||||
try:
|
||||
db.execute(text("""
|
||||
INSERT INTO patch_planning_row_log (row_id, action, details, performed_by)
|
||||
VALUES (:rid, 'yum_dryrun', :de, :uid)
|
||||
"""), {"rid": row_id,
|
||||
"de": json.dumps({k: v for k, v in result.items() if k != "stdout_tail"},
|
||||
ensure_ascii=False),
|
||||
"uid": user.get("uid")})
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
print(f"[iexec_yum_dryrun] 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=...`."""
|
||||
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 yum_update
|
||||
result = yum_update(hostname, row.effective_excludes)
|
||||
|
||||
try:
|
||||
db.execute(text("""
|
||||
INSERT INTO patch_planning_row_log (row_id, action, details, performed_by)
|
||||
VALUES (:rid, 'yum_update', :de, :uid)
|
||||
"""), {"rid": row_id,
|
||||
"de": json.dumps({k: v for k, v in result.items() if k != "stdout_tail"},
|
||||
ensure_ascii=False),
|
||||
"uid": user.get("uid")})
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
print(f"[iexec_yum_update] audit log failed: {e}")
|
||||
|
||||
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)
|
||||
|
||||
138
app/services/patch_run_service.py
Normal file
138
app/services/patch_run_service.py
Normal file
@ -0,0 +1,138 @@
|
||||
"""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.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, Any, List
|
||||
|
||||
from .realtime_audit_service import _resolve, _connect, PARAMIKO_OK
|
||||
|
||||
log = logging.getLogger("patchcenter.patch_run")
|
||||
|
||||
# Timeouts SSH (secondes)
|
||||
TIMEOUT_DRYRUN = 60
|
||||
TIMEOUT_UPDATE = 1800 # 30 min — yum update peut être long
|
||||
|
||||
# Whitelist caractères autorisés dans un nom de paquet (anti-injection shell)
|
||||
EXCLUDE_RE = re.compile(r"^[A-Za-z0-9._*\-/+]+$")
|
||||
|
||||
|
||||
def _safe_excludes(raw) -> List[str]:
|
||||
"""Normalise et filtre la string `effective_excludes` (séparée par espaces).
|
||||
Rejette les patterns suspects pour éviter toute injection."""
|
||||
if not raw:
|
||||
return []
|
||||
parts = str(raw).split()
|
||||
return [p for p in parts if EXCLUDE_RE.match(p)]
|
||||
|
||||
|
||||
def _build_cmd(mode: str, excludes: List[str]) -> str:
|
||||
"""mode = 'dryrun' (--assumeno) ou 'update' (-y)."""
|
||||
flag = "--assumeno" if mode == "dryrun" else "-y"
|
||||
parts = ["sudo", "-n", "yum", "update", flag]
|
||||
for e in excludes:
|
||||
parts.append(f"--exclude={e}")
|
||||
parts.append("2>&1")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _exec(client, cmd: str, timeout: int) -> Dict[str, Any]:
|
||||
try:
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout)
|
||||
out = stdout.read().decode("utf-8", "replace")
|
||||
err = stderr.read().decode("utf-8", "replace")
|
||||
rc = stdout.channel.recv_exit_status()
|
||||
return {"rc": rc, "stdout": out, "stderr": err}
|
||||
except Exception as e:
|
||||
return {"rc": -1, "stdout": "", "stderr": f"exec error: {e}"}
|
||||
|
||||
|
||||
def _open_ssh(hostname: str):
|
||||
target = _resolve(hostname)
|
||||
if not target:
|
||||
return None, None, "DNS résolution impossible"
|
||||
if not PARAMIKO_OK:
|
||||
return None, target, "paramiko non disponible côté serveur PatchCenter"
|
||||
client = _connect(target, hostname)
|
||||
if not client:
|
||||
return None, target, "Connexion SSH échouée"
|
||||
return client, target, None
|
||||
|
||||
|
||||
def _summary(stdout: str) -> List[str]:
|
||||
"""Extrait les lignes-clé du plan yum (compte de paquets, 'Nothing to do'…)."""
|
||||
keys = (
|
||||
"package(s)", "paquet(s)",
|
||||
"nothing to do", "rien à faire",
|
||||
"transaction summary", "résumé de la transaction",
|
||||
"install ", "installation ", "upgrade ", "mise à niveau",
|
||||
"complete!", "terminé !",
|
||||
"no packages marked for update", "no package",
|
||||
)
|
||||
summary = []
|
||||
for ln in stdout.splitlines():
|
||||
ll = ln.lower().strip()
|
||||
if any(k in ll for k in keys):
|
||||
summary.append(ln.strip())
|
||||
if len(summary) >= 25:
|
||||
break
|
||||
return summary
|
||||
|
||||
|
||||
def yum_dryrun(hostname: str, excludes_raw) -> Dict[str, Any]:
|
||||
"""Lance un dry-run (--assumeno). rc=0 → rien à faire, rc=1 → updates dispo (normal)."""
|
||||
excludes = _safe_excludes(excludes_raw)
|
||||
client, target, err = _open_ssh(hostname)
|
||||
if err:
|
||||
return {"ok": False, "detail": err, "target": target, "excludes": excludes}
|
||||
try:
|
||||
cmd = _build_cmd("dryrun", excludes)
|
||||
r = _exec(client, cmd, TIMEOUT_DRYRUN)
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Avec --assumeno : rc=0 si "Nothing to do", rc=1 si plan d'updates dispo
|
||||
ok = r["rc"] in (0, 1)
|
||||
return {
|
||||
"ok": ok,
|
||||
"rc": r["rc"],
|
||||
"cmd": cmd,
|
||||
"target": target,
|
||||
"excludes": excludes,
|
||||
"summary": _summary(r["stdout"]),
|
||||
"stdout_tail": r["stdout"][-3000:],
|
||||
"stderr": r["stderr"][:500] if r["stderr"] else "",
|
||||
}
|
||||
|
||||
|
||||
def yum_update(hostname: str, excludes_raw) -> Dict[str, Any]:
|
||||
"""Lance le vrai patch (-y)."""
|
||||
excludes = _safe_excludes(excludes_raw)
|
||||
client, target, err = _open_ssh(hostname)
|
||||
if err:
|
||||
return {"ok": False, "detail": err, "target": target, "excludes": excludes}
|
||||
try:
|
||||
cmd = _build_cmd("update", excludes)
|
||||
r = _exec(client, cmd, TIMEOUT_UPDATE)
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
ok = (r["rc"] == 0)
|
||||
return {
|
||||
"ok": ok,
|
||||
"rc": r["rc"],
|
||||
"cmd": cmd,
|
||||
"target": target,
|
||||
"excludes": excludes,
|
||||
"summary": _summary(r["stdout"]),
|
||||
"stdout_tail": r["stdout"][-5000:],
|
||||
"stderr": r["stderr"][:500] if r["stderr"] else "",
|
||||
}
|
||||
@ -18,7 +18,7 @@
|
||||
<span class="text-gray-500">→</span>
|
||||
<span class="px-3 py-1 rounded bg-cyber-green/20 text-cyber-green font-bold">2. Snapshot</span>
|
||||
<span class="text-gray-500">→</span>
|
||||
<span class="px-3 py-1 rounded bg-cyber-border text-gray-500">3. Patch yum</span>
|
||||
<span class="px-3 py-1 rounded bg-cyber-blue/20 text-cyber-blue font-bold">3. Patch yum</span>
|
||||
</div>
|
||||
|
||||
<div class="card p-3 mb-4">
|
||||
@ -50,6 +50,8 @@
|
||||
<th class="text-left p-1">Satellite</th>
|
||||
<th class="text-left p-1">Verdict</th>
|
||||
<th class="text-left p-1">Snapshot</th>
|
||||
<th class="text-left p-1">Dry-run</th>
|
||||
<th class="text-left p-1">Patch</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="check-tbody">
|
||||
@ -67,9 +69,11 @@
|
||||
<td class="p-1 cell-sat text-gray-500">·</td>
|
||||
<td class="p-1 cell-overall text-gray-500">en attente</td>
|
||||
<td class="p-1 cell-snap text-gray-500">·</td>
|
||||
<td class="p-1 cell-dry text-gray-500">·</td>
|
||||
<td class="p-1 cell-patch text-gray-500">·</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="12" class="p-2 text-gray-500">Aucune ligne éligible.</td></tr>
|
||||
<tr><td colspan="14" class="p-2 text-gray-500">Aucune ligne éligible.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -81,17 +85,27 @@
|
||||
<pre id="details-pane" class="bg-cyber-bg p-2 text-[11px] whitespace-pre-wrap overflow-x-auto" style="max-height:400px;"></pre>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-4">
|
||||
<div class="flex justify-between items-center mt-4 flex-wrap gap-2">
|
||||
<span id="run-summary" class="text-xs text-gray-400"></span>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button id="btn-step2" class="btn-sm bg-cyber-green/20 text-cyber-green px-4 py-2 text-xs" disabled>
|
||||
→ Step 2 (snapshot vCenter)
|
||||
</button>
|
||||
<button id="btn-dryrun" class="btn-sm bg-cyber-yellow/20 text-cyber-yellow px-4 py-2 text-xs" disabled title="yum update --assumeno : simule sans appliquer">
|
||||
→ Step 3a (dry-run yum)
|
||||
</button>
|
||||
<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">
|
||||
→ Step 3b (patcher yum)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const btnRun = document.getElementById('btn-run-all');
|
||||
const btnStep2 = document.getElementById('btn-step2');
|
||||
const btnDryrun = document.getElementById('btn-dryrun');
|
||||
const btnStep3 = document.getElementById('btn-step3');
|
||||
const tbody = document.getElementById('check-tbody');
|
||||
const summary = document.getElementById('run-summary');
|
||||
const detailsCard = document.getElementById('details-card');
|
||||
@ -174,6 +188,16 @@
|
||||
btnStep2.disabled = (okCount === 0);
|
||||
});
|
||||
|
||||
function refreshStepButtons(){
|
||||
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
|
||||
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);
|
||||
btnDryrun.disabled = (snapOk.length === 0);
|
||||
btnStep3.disabled = (dryOk.length === 0);
|
||||
}
|
||||
|
||||
btnStep2.addEventListener('click', async () => {
|
||||
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
|
||||
const okTrs = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok');
|
||||
@ -205,9 +229,72 @@
|
||||
cell.innerHTML = '<span class="text-cyber-red">✗ erreur réseau</span>';
|
||||
}
|
||||
}
|
||||
btnStep2.disabled = false;
|
||||
btnRun.disabled = false;
|
||||
summary.innerHTML += ' · Snapshot : ✓ ' + okCount + ' / ✗ ' + koCount;
|
||||
refreshStepButtons();
|
||||
});
|
||||
|
||||
btnDryrun.addEventListener('click', async () => {
|
||||
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
|
||||
const targets = trs.filter(tr => tr._snapData && tr._snapData.ok);
|
||||
if (!targets.length) { alert('Aucun serveur avec snapshot OK'); return; }
|
||||
if (!confirm('Lancer dry-run yum (simulation) sur ' + targets.length + ' serveur(s) ?')) return;
|
||||
btnDryrun.disabled = true; btnStep3.disabled = true;
|
||||
let okCount = 0, koCount = 0;
|
||||
for (const tr of targets) {
|
||||
const cell = tr.querySelector('.cell-dry');
|
||||
cell.innerHTML = '<span class="text-cyber-yellow">… dry-run</span>';
|
||||
try {
|
||||
const r = await fetch('/patching/iexec/yum-dryrun/' + tr.dataset.rowId, {method:'POST'});
|
||||
const j = await r.json();
|
||||
tr._dryData = j;
|
||||
if (j.ok) {
|
||||
okCount++;
|
||||
const sumLine = (j.summary || []).slice(-2).join(' / ') || 'plan OK';
|
||||
cell.innerHTML = '<span class="text-cyber-green">✓ </span><span class="text-[10px] text-gray-300" title="' + escapeHTML(sumLine) + '">' + escapeHTML(sumLine.slice(0,80)) + '</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 += ' · Dry-run : ✓ ' + okCount + ' / ✗ ' + koCount;
|
||||
refreshStepButtons();
|
||||
});
|
||||
|
||||
btnStep3.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('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;
|
||||
for (const tr of targets) {
|
||||
const cell = tr.querySelector('.cell-patch');
|
||||
cell.innerHTML = '<span class="text-cyber-yellow">… patch en cours</span>';
|
||||
try {
|
||||
const r = await fetch('/patching/iexec/yum-update/' + tr.dataset.rowId, {method:'POST'});
|
||||
const j = await r.json();
|
||||
tr._patchData = j;
|
||||
if (j.ok) {
|
||||
okCount++;
|
||||
const sumLine = (j.summary || []).slice(-2).join(' / ') || 'patch OK';
|
||||
cell.innerHTML = '<span class="text-cyber-green">✓ </span><span class="text-[10px] text-gray-300" title="' + escapeHTML(sumLine) + '">' + escapeHTML(sumLine.slice(0,80)) + '</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 += ' · Patch : ✓ ' + okCount + ' / ✗ ' + koCount;
|
||||
refreshStepButtons();
|
||||
});
|
||||
|
||||
// Click sur une ligne → afficher les détails
|
||||
|
||||
Loading…
Reference in New Issue
Block a user