"""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]}