"""Service de deploiement Qualys Cloud Agent via SSH""" import os import re import logging from datetime import datetime log = logging.getLogger(__name__) AGENTS_DIR = "/opt/patchcenter/agents" try: import paramiko PARAMIKO_OK = True except ImportError: PARAMIKO_OK = False def _extract_version(filename): """Extrait la version depuis le nom du fichier""" m = re.search(r'(\d+\.\d+\.\d+[\.\-]\d+)', filename) return m.group(1) if m else "inconnue" def list_packages(): """Liste les packages disponibles dans /opt/patchcenter/agents/""" packages = {"deb": [], "rpm": []} if not os.path.isdir(AGENTS_DIR): return packages for f in sorted(os.listdir(AGENTS_DIR), reverse=True): path = os.path.join(AGENTS_DIR, f) size_mb = round(os.path.getsize(path) / 1024 / 1024, 1) version = _extract_version(f) entry = {"name": f, "path": path, "size": size_mb, "version": version} if f.endswith(".deb"): packages["deb"].append(entry) elif f.endswith(".rpm"): packages["rpm"].append(entry) return packages def _get_ssh_client(hostname, ssh_user, ssh_key_path, ssh_port=22): """Crée un client SSH paramiko""" if not PARAMIKO_OK: return None, "paramiko non disponible" client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: for cls in [paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey]: try: key = cls.from_private_key_file(ssh_key_path) client.connect(hostname, port=ssh_port, username=ssh_user, pkey=key, timeout=15, look_for_keys=False, allow_agent=False) return client, None except Exception: continue return None, f"Impossible de charger la cle {ssh_key_path}" except Exception as e: return None, str(e) def _run_cmd(client, cmd, sudo=False, timeout=120): """Execute une commande SSH""" if sudo: _, stdout_id, _ = client.exec_command("id -u", timeout=5) uid = stdout_id.read().decode().strip() if uid != "0": cmd = f"sudo {cmd}" _, stdout, stderr = client.exec_command(cmd, timeout=timeout) exit_code = stdout.channel.recv_exit_status() out = stdout.read().decode("utf-8", errors="replace") err = stderr.read().decode("utf-8", errors="replace") return exit_code, out, err def check_agent(hostname, ssh_user, ssh_key_path, ssh_port=22): """Vérifie le statut de l'agent Qualys sur un serveur""" client, error = _get_ssh_client(hostname, ssh_user, ssh_key_path, ssh_port) if not client: return {"hostname": hostname, "status": "CONNECTION_FAILED", "detail": error} result = {"hostname": hostname} # Check if installed (rpm -q returns 0 only if installed, dpkg -s returns 0 only if installed) installed = False code, out, _ = _run_cmd(client, "rpm -q qualys-cloud-agent 2>/dev/null") if code == 0 and "not installed" not in out.lower() and "n'est pas install" not in out.lower(): installed = True else: code, out, _ = _run_cmd(client, "dpkg -s qualys-cloud-agent 2>/dev/null") if code == 0 and "install ok installed" in out.lower(): installed = True if not installed: result["status"] = "NOT_INSTALLED" result["detail"] = "Agent non installe" result["version"] = "" result["service_status"] = "" client.close() return result # Check if running code, out, _ = _run_cmd(client, "systemctl is-active qualys-cloud-agent 2>/dev/null") status = out.strip() result["service_status"] = status # Get version via package manager code, out, _ = _run_cmd(client, "rpm -q --qf '%{VERSION}-%{RELEASE}' qualys-cloud-agent 2>/dev/null || dpkg-query -W -f='${Version}' qualys-cloud-agent 2>/dev/null") version = out.strip() m = re.search(r'(\d+\.\d+\.\d+[\.\-]\d+)', version) result["version"] = m.group(1) if m else version[:50] # Get last checkin from log code, out, _ = _run_cmd(client, "tail -5 /var/log/qualys/qualys-cloud-agent.log 2>/dev/null | grep 'HTTP response code: 200' | tail -1 | awk '{print $1, $2}'") result["last_checkin"] = out.strip()[:25] if status == "active": result["status"] = "ACTIVE" result["detail"] = "Agent actif" elif status == "inactive" or status == "dead": result["status"] = "INACTIVE" result["detail"] = "Agent installe mais inactif" else: result["status"] = "UNKNOWN" result["detail"] = f"Statut: {status}" client.close() return result def _compare_versions(v1, v2): """Compare deux versions. Retourne -1 (v1v2)""" def to_parts(v): return [int(x) for x in re.split(r'[.\-]', v) if x.isdigit()] p1, p2 = to_parts(v1 or ""), to_parts(v2 or "") for a, b in zip(p1, p2): if a < b: return -1 if a > b: return 1 if len(p1) < len(p2): return -1 if len(p1) > len(p2): return 1 return 0 def deploy_agent(hostname, ssh_user, ssh_key_path, ssh_port, os_family, package_path, activation_id, customer_id, server_uri, on_line=None, on_stage=None, force_downgrade=False): """Deploie l'agent Qualys sur un serveur (install, upgrade ou downgrade)""" def emit(msg): if on_line: on_line(msg) log.info(f"[{hostname}] {msg}") def stage(name, detail=""): if on_stage: on_stage(name, detail) stage("connecting", f"Connexion SSH {ssh_user}@{hostname}:{ssh_port}") emit(f"Connexion SSH {ssh_user}@{hostname}:{ssh_port}...") client, error = _get_ssh_client(hostname, ssh_user, ssh_key_path, ssh_port) if not client: emit(f"ERREUR connexion: {error}") stage("failed", error) return {"hostname": hostname, "status": "FAILED", "detail": error} result = {"hostname": hostname, "status": "PENDING"} pkg_name = os.path.basename(package_path) pkg_version = _extract_version(pkg_name) is_deb = pkg_name.endswith(".deb") try: # 1. Detect OS if not provided if not os_family: code, out, _ = _run_cmd(client, "cat /etc/os-release 2>/dev/null | head -3") os_family = "linux" if "debian" in out.lower() or "ubuntu" in out.lower(): os_family = "debian" elif "red hat" in out.lower() or "centos" in out.lower() or "rocky" in out.lower(): os_family = "rhel" emit(f"OS detecte: {os_family}") # 2. Check installed version stage("checking", "Verification agent existant") installed_version = None code, out, _ = _run_cmd(client, "rpm -q qualys-cloud-agent 2>/dev/null") if code == 0 and "not installed" not in out.lower() and "n'est pas install" not in out.lower(): m = re.search(r'(\d+\.\d+\.\d+[\.\-]\d+)', out) installed_version = m.group(1) if m else None else: code, out, _ = _run_cmd(client, "dpkg-query -W -f='${Version}' qualys-cloud-agent 2>/dev/null") if code == 0 and out.strip(): m = re.search(r'(\d+\.\d+\.\d+[\.\-]\d+)', out) installed_version = m.group(1) if m else None if installed_version: cmp = _compare_versions(pkg_version, installed_version) emit(f"Version installee: {installed_version}, package: {pkg_version}") if cmp == 0: # Meme version code, out, _ = _run_cmd(client, "systemctl is-active qualys-cloud-agent 2>/dev/null") if out.strip() == "active": emit(f"Meme version ({installed_version}) et agent actif - skip") stage("already_installed", f"v{installed_version} deja active") result["status"] = "ALREADY_INSTALLED" result["detail"] = f"v{installed_version} deja installee et active" client.close() return result else: emit(f"Meme version mais service {out.strip()} - reinstallation") elif cmp < 0: # Downgrade if not force_downgrade: emit(f"DOWNGRADE refuse: {installed_version} → {pkg_version}") stage("downgrade_refused", f"v{installed_version} > v{pkg_version}") result["status"] = "DOWNGRADE_REFUSED" result["detail"] = f"Version installee ({installed_version}) plus recente que le package ({pkg_version}). Cochez 'Forcer le downgrade'." client.close() return result else: emit(f"DOWNGRADE force: {installed_version} → {pkg_version}") else: emit(f"UPGRADE: {installed_version} → {pkg_version}") # 3. Copy package pkg_size = os.path.getsize(package_path) // 1024 // 1024 stage("copying", f"Copie {pkg_name} ({pkg_size} Mo)") emit(f"Copie {pkg_name} ({pkg_size} Mo)...") sftp = client.open_sftp() sftp.put(package_path, f"/tmp/{pkg_name}") sftp.close() emit("Copie terminee") remote_path = f"/tmp/{pkg_name}" # 4. Install / Upgrade / Downgrade if is_deb: stage("installing", "Installation (dpkg)") emit("Installation (dpkg)...") code, out, err = _run_cmd(client, f"dpkg --install {remote_path}", sudo=True, timeout=120) else: if force_downgrade and installed_version: stage("installing", f"Downgrade (rpm) {installed_version} → {pkg_version}") emit(f"Downgrade (rpm --oldpackage)...") code, out, err = _run_cmd(client, f"rpm -Uvh --nosignature --oldpackage {remote_path}", sudo=True, timeout=120) else: stage("installing", "Installation/Upgrade (rpm)") emit("Installation/Upgrade (rpm -Uvh)...") code, out, err = _run_cmd(client, f"rpm -Uvh --nosignature {remote_path}", sudo=True, timeout=120) if code != 0 and "already installed" not in (out + err).lower(): emit(f"ERREUR installation (code {code}): {err[:200]}") stage("failed", f"Installation echouee: {err[:100]}") result["status"] = "INSTALL_FAILED" result["detail"] = err[:200] client.close() return result emit("Installation OK") # 5. Activate stage("activating", "Activation de l'agent") emit("Activation de l'agent...") activate_cmd = ( f"/usr/local/qualys/cloud-agent/bin/qualys-cloud-agent.sh " f"ActivationId={activation_id} " f"CustomerId={customer_id} " f"ServerUri={server_uri} " f"ProviderName=NONE" ) code, out, err = _run_cmd(client, activate_cmd, sudo=True, timeout=60) if code != 0: emit(f"ERREUR activation (code {code}): {err[:200]}") stage("failed", f"Activation echouee: {err[:100]}") result["status"] = "ACTIVATE_FAILED" result["detail"] = err[:200] client.close() return result emit("Activation OK") # 6. Restart service stage("restarting", "Redemarrage du service") emit("Redemarrage du service...") _run_cmd(client, "systemctl restart qualys-cloud-agent", sudo=True) # 7. Verify stage("verifying", "Verification du service") code, out, _ = _run_cmd(client, "systemctl is-active qualys-cloud-agent") action = "Upgrade" if installed_version else "Install" if out.strip() == "active": emit(f"Agent {action.lower()} OK et actif !") stage("success", f"{action} OK — v{pkg_version} active") result["status"] = "SUCCESS" result["detail"] = f"{action} v{pkg_version} OK" else: emit(f"Agent installe mais statut: {out.strip()}") stage("partial", f"Service: {out.strip()}") result["status"] = "PARTIAL" result["detail"] = f"Installe, service: {out.strip()}" # 8. Cleanup _run_cmd(client, f"rm -f {remote_path}") except Exception as e: emit(f"ERREUR: {e}") stage("failed", str(e)[:100]) result["status"] = "FAILED" result["detail"] = str(e)[:200] client.close() return result # ═══════════════════════════════════════════════ # Background job manager # ═══════════════════════════════════════════════ import threading import uuid import time _deploy_jobs = {} # job_id -> job dict def start_deploy_job(servers, ssh_key, package_deb, package_rpm, activation_id, customer_id, server_uri, force_downgrade=False): """Lance un job de deploiement en arriere-plan. Retourne le job_id.""" job_id = str(uuid.uuid4())[:8] job = { "id": job_id, "started_at": time.time(), "total": len(servers), "done": 0, "servers": {}, "log": [], "finished": False, } for srv in servers: job["servers"][srv["hostname"]] = { "hostname": srv["hostname"], "stage": "pending", "detail": "En attente", "status": None, "result": None, } _deploy_jobs[job_id] = job def _run(): threads = [] for srv in servers: t = threading.Thread(target=_deploy_one, args=(job, srv, ssh_key, package_deb, package_rpm, activation_id, customer_id, server_uri, force_downgrade)) t.daemon = True threads.append(t) t.start() for t in threads: t.join() job["finished"] = True job["finished_at"] = time.time() master = threading.Thread(target=_run, daemon=True) master.start() return job_id def _deploy_one(job, srv, ssh_key, package_deb, package_rpm, activation_id, customer_id, server_uri, force_downgrade=False): hostname = srv["hostname"] def on_stage(stage, detail=""): job["servers"][hostname]["stage"] = stage job["servers"][hostname]["detail"] = detail def on_line(msg): job["log"].append(f"[{hostname}] {msg}") osv = (srv.get("os_version") or "").lower() if any(k in osv for k in ("centos", "red hat", "rhel", "rocky", "oracle", "fedora", "alma")): pkg = package_rpm else: pkg = package_deb if not pkg or not os.path.exists(pkg): job["servers"][hostname]["stage"] = "failed" job["servers"][hostname]["detail"] = f"Package introuvable: {pkg}" job["servers"][hostname]["status"] = "FAILED" job["done"] += 1 return result = deploy_agent( hostname=hostname, ssh_user=srv.get("ssh_user") or "root", ssh_key_path=ssh_key, ssh_port=srv.get("ssh_port") or 22, os_family=srv.get("os_family"), package_path=pkg, activation_id=activation_id, customer_id=customer_id, server_uri=server_uri, on_line=on_line, on_stage=on_stage, force_downgrade=force_downgrade, ) job["servers"][hostname]["status"] = result["status"] job["servers"][hostname]["result"] = result job["done"] += 1 def get_deploy_job(job_id): """Retourne l'etat d'un job de deploiement.""" return _deploy_jobs.get(job_id) def list_deploy_jobs(): """Liste les jobs recents (< 1h).""" now = time.time() return {jid: j for jid, j in _deploy_jobs.items() if now - j["started_at"] < 3600}