471 lines
18 KiB
Python
471 lines
18 KiB
Python
"""Service exécution patch 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
|
|
import socket
|
|
from datetime import datetime
|
|
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 extract_problem_packages(stdout: str) -> List[str]:
|
|
"""Heuristique : extrait les noms de paquets en cause dans les erreurs yum.
|
|
Couvre les motifs courants (multilib, requires, cannot install, conflict).
|
|
Retourne une liste de noms (string courte type 'glibc', 'httpd')."""
|
|
found: set = set()
|
|
|
|
def add(name: str):
|
|
if not name:
|
|
return
|
|
# Strip arch suffix : .x86_64, .i686, .noarch
|
|
name = re.sub(r"\.(x86_64|i[3-6]86|noarch|aarch64)$", "", name)
|
|
# Strip version suffix : -1.2.3-rc1.el8 etc. (garde juste le nom)
|
|
m = re.match(r"^([A-Za-z0-9._+]+?)(?:-\d.*)?$", name)
|
|
if m:
|
|
name = m.group(1)
|
|
# Validation finale
|
|
if EXCLUDE_RE.match(name) and len(name) >= 2:
|
|
found.add(name)
|
|
|
|
# 1. Multilib : "protection ... : glibc-2.17-326.i686 != glibc-2.17-325.x86_64"
|
|
for m in re.finditer(r":\s+(\S+)\s*!=\s*(\S+)", stdout):
|
|
for v in m.groups():
|
|
add(v)
|
|
|
|
# 2. dnf/yum 4 : "Cannot install the best update candidate for package X"
|
|
for m in re.finditer(
|
|
r"(?:Cannot install|Impossible d.installer).*?package\s+(\S+)",
|
|
stdout, re.IGNORECASE):
|
|
add(m.group(1))
|
|
|
|
# 3. "Problem: package X requires Y" / "nécessite Y"
|
|
for m in re.finditer(
|
|
r"(?:Problem|Probl.me).*?package\s+(\S+)",
|
|
stdout, re.IGNORECASE):
|
|
add(m.group(1))
|
|
|
|
# 4. "Error: Package: X-1.2.3"
|
|
for m in re.finditer(r"(?:Error|Erreur):\s*Package:?\s+(\S+)", stdout):
|
|
add(m.group(1))
|
|
|
|
# 5. "conflicts with X provided by Y"
|
|
for m in re.finditer(
|
|
r"conflicts? with\s+(\S+)|conflit avec\s+(\S+)",
|
|
stdout, re.IGNORECASE):
|
|
for v in m.groups():
|
|
if v:
|
|
add(v)
|
|
|
|
return sorted(found)
|
|
|
|
|
|
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_stream_lines(hostname: str, excludes_raw, mode: str):
|
|
"""Generator SSE-friendly : yield des dicts {type, ...} en live.
|
|
mode = 'dryrun' (--assumeno) ou 'update' (-y)."""
|
|
excludes = _safe_excludes(excludes_raw)
|
|
if mode not in ("dryrun", "update"):
|
|
yield {"type": "error", "msg": f"mode invalide: {mode}"}
|
|
return
|
|
client, target, err = _open_ssh(hostname)
|
|
if err:
|
|
yield {"type": "error", "msg": err}
|
|
return
|
|
cmd = _build_cmd(mode, excludes)
|
|
yield {"type": "cmd", "cmd": cmd, "target": target,
|
|
"excludes": excludes, "hostname": hostname}
|
|
full_lines: List[str] = []
|
|
try:
|
|
stdin, stdout, stderr = client.exec_command(cmd, get_pty=False)
|
|
# Lecture ligne par ligne ; yum bufferise peu son stdout sur opérations longues
|
|
for line in iter(stdout.readline, ""):
|
|
ln = line.rstrip("\n")
|
|
full_lines.append(ln)
|
|
yield {"type": "line", "data": ln}
|
|
rc = stdout.channel.recv_exit_status()
|
|
# Détection de paquets problématiques si KO
|
|
problems: List[str] = []
|
|
if mode == "update" and rc != 0:
|
|
problems = extract_problem_packages("\n".join(full_lines))
|
|
elif mode == "dryrun" and rc not in (0, 1):
|
|
problems = extract_problem_packages("\n".join(full_lines))
|
|
yield {"type": "end", "rc": rc, "problems": problems}
|
|
except Exception as e:
|
|
yield {"type": "error", "msg": f"exec error: {e}"}
|
|
finally:
|
|
try:
|
|
client.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
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 "",
|
|
}
|
|
|
|
|
|
# ─── 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 "",
|
|
}
|
|
|
|
|
|
# ─── 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]}
|