"""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 import time from datetime import datetime from typing import Dict, Any, List from .realtime_audit_service import _resolve, _connect, _candidate_targets, PARAMIKO_OK log = logging.getLogger("patchcenter.patch_run") # Timeouts SSH (secondes) TIMEOUT_DRYRUN = 60 TIMEOUT_UPDATE = 1800 # 30 min — yum update peut être long # Streaming SSE : battement de cœur pendant les phases silencieuses de yum # (refresh metadata / résolution de deps n'émettent rien sur stdout pendant # plusieurs secondes → sans heartbeat, un proxy idle coupe la connexion). HEARTBEAT_SECS = 10 # Durée max d'un stream avant abandon (le dry-run reste borné, l'update est long). STREAM_MAX_DRYRUN = 300 STREAM_MAX_UPDATE = TIMEOUT_UPDATE # 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): """Ouvre une session SSH en réutilisant la logique du check pré-patching : on itère les candidats DNS (_candidate_targets) et on tente _connect sur chacun. _connect gère PSMP / clé / password — il NE faut PAS filtrer sur un TCP/22 direct (cf. _resolve), sinon tout hôte PSMP (port 22 non joignable en direct) échoue avec un faux 'DNS résolution impossible'.""" if not PARAMIKO_OK: return None, None, "paramiko non disponible côté serveur PatchCenter" candidates = _candidate_targets(hostname) if not candidates: return None, None, "Aucun candidat DNS pour cet hôte" errors: List[str] = [] tried: List[str] = [] for cand in candidates: tried.append(cand) errs: List[str] = [] try: client = _connect(cand, hostname, errors=errs) except Exception as e: errs.append(f"{type(e).__name__}: {e}") client = None if client: return client, cand, None errors.append(f"{cand}: " + (" | ".join(errs) if errs else "échec")) detail = "Connexion SSH échouée — " + " ; ".join(errors[-3:]) return None, (tried[0] if tried else None), detail 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] = [] max_secs = STREAM_MAX_UPDATE if mode == "update" else STREAM_MAX_DRYRUN try: # Keepalive transport : évite que la session SSH meure pendant un long # silence de yum (résolution de deps, téléchargements). try: client.get_transport().set_keepalive(HEARTBEAT_SECS) except Exception: pass stdin, stdout, stderr = client.exec_command(cmd, get_pty=False) chan = stdout.channel # Lecture non bloquante : recv() rend la main toutes les secondes pour # qu'on puisse émettre un heartbeat même si yum n'a rien écrit. chan.settimeout(1.0) buf = "" start = time.monotonic() last_beat = start while True: try: chunk = chan.recv(8192) except socket.timeout: chunk = None if chunk: buf += chunk.decode("utf-8", "replace") while "\n" in buf: ln, buf = buf.split("\n", 1) ln = ln.rstrip("\r") full_lines.append(ln) yield {"type": "line", "data": ln} last_beat = time.monotonic() continue if chunk == b"": # EOF : la commande distante est terminée break # chunk is None → timeout de lecture (yum silencieux) now = time.monotonic() if now - start > max_secs: yield {"type": "error", "msg": f"timeout stream ({max_secs}s) — commande interrompue côté PatchCenter"} try: chan.close() except Exception: pass return if now - last_beat >= HEARTBEAT_SECS: last_beat = now yield {"type": "ping"} # Flush d'une éventuelle dernière ligne sans \n final if buf: ln = buf.rstrip("\r") full_lines.append(ln) yield {"type": "line", "data": ln} rc = chan.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]}