patchcenter/app/services/patch_run_service.py

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