#!/usr/bin/env python3 """Activer le pare-feu Proxmox d'UNE VM de locataire, en prouvant qu'il ne coupe rien. POURQUOI UNE PROCEDURE, ET PAS SEULEMENT `proxmox-fw-appliquer` (2026-09-28). Aucune des 26 VM des locataires n'avait son pare-feu actif : les reconstructions clonent sans ses options. Tout activer d'un coup ouvrait 26 pannes possibles, et chaque defaut ne s'est montre qu'a l'activation d'UNE VM : le ping de supervision qu'aucune regle ne portait (l'ICMP etait saute par le devis), le tunnel d'administration du locataire que nftables admettait et Proxmox non. Une VM reconstruite perd de nouveau ses options : cette procedure servira encore. CE QU'ELLE FAIT, POUR UNE VM : 1. MATRICE tiree du DEVIS lui-meme : pour chaque regle des groupes de la VM, CHAQUE membre de la source autorisee tente VM:port. On teste ce qui est promis, pas ce qu'on croit ; 2. OBSERVATION : les connexions entrantes ETABLIES sur la VM (trois releves), confrontees aux regles. La matrice ne voit que le DECLARE ; un flux reel non declare la passerait, puis serait coupe. Un seul flux hors regles : REFUS d'activer ; 3. matrice AVANT. Un echec deja present : REFUS — il faut le comprendre d'abord. Sauf si rien n'ECOUTE sur ce port hors de 127.0.0.1 : « sans objet » (un nginx lie en local derriere une passerelle SSO, un frontal sans site) ; 4. ACTIVATION par le runner du site (seul a joindre l'API du cluster) : `--vm ` ; 5. matrice APRES, comparee ; puis sondes de sante relancees et critiques d'Icinga. python3 scripts/eprouver_parefeu.py --instance OPS-X --hote idm-01 --plan python3 scripts/eprouver_parefeu.py --instance OPS-X --hote idm-01 --activer LIMITE CONNUE : un flux UDP est teste en TCP sur le meme port (la matrice ne sait pas ouvrir une « connexion » UDP). Pour un resolveur, verifier une vraie resolution (`dig +notcp`). """ from __future__ import annotations import argparse import ipaddress import json import os import re import subprocess import sys import time from pathlib import Path RACINE = Path(__file__).resolve().parent.parent sys.path.insert(0, str(RACINE / "scripts")) from devis_reseau import DOSSIER_INSTANCES, admin_avec_tunnel # noqa: E402 def _inventaire(instance: str) -> Path: return DOSSIER_INSTANCES / instance / "inventories" / "principal" / "hosts.yml" def _ip_par_hote(instance: str) -> dict[str, str]: import yaml table: dict[str, str] = {} def w(n): if isinstance(n, dict): for k, v in n.items(): if k == "hosts" and isinstance(v, dict): for h, hv in v.items(): if isinstance(hv, dict) and hv.get("ansible_host"): table[h] = hv["ansible_host"] else: w(v) w(yaml.safe_load(_inventaire(instance).read_text(encoding="utf-8"))) return table def _ansible(instance: str, hotes: str, commande: str) -> dict[str, str]: """{hote: sortie} d'une commande shell jouee en root sur des hotes du locataire.""" r = subprocess.run(["ansible", "-i", str(_inventaire(instance)), hotes, "-b", "-m", "shell", "-a", commande], capture_output=True, text=True, cwd=RACINE) sorties, hote = {}, None for ligne in r.stdout.splitlines(): m = re.match(r"^(\S+) \| \w+ \| rc=\d+ >>$", ligne) if m: hote = m.group(1) sorties[hote] = "" elif hote: sorties[hote] += ligne + "\n" return sorties def matrice(instance: str, hote: str) -> tuple[list[dict], int]: env = dict(os.environ, SETOPS_INSTANCE=str(DOSSIER_INSTANCES / instance)) r = subprocess.run([sys.executable, "scripts/devis_proxmox_fw.py", "--json"], capture_output=True, text=True, cwd=RACINE, env=env) if r.returncode != 0: raise SystemExit("Le devis ne se genere pas :\n" + r.stderr[:400]) devis = json.loads(r.stdout) ips = _ip_par_hote(instance) h_par_ip = {ip: h for h, ip in ips.items()} # LE BLOC DE CE LOCATAIRE, PAS LE PREMIER QUI PORTE CE NOM. Les locataires partagent # leurs noms d'hotes (`ops-01` existe chez chacun) : prendre la premiere affectation # venue visait la VM d'un autre — constate au premier essai de ce script. for b in (x for x in devis["blocs"] if x.get("tenant") == instance): a = next((x for x in b["affectations"] if x["hote"] == hote), None) if a is None: continue groupes = {g["nom"]: g for g in b["groupes"]} tests = [] for gn in a["groupes"]: for regle in groupes[gn]["regles"]: membres = b["ipsets"].get(regle["source"].lstrip("+"), {}).get("membres", []) for m in membres: src = h_par_ip.get(m) if src and src != hote: tests.append({"src": src, "ip": ips[hote], "proto": regle["proto"], "port": str(regle.get("dport") or regle.get("icmp_type")), "groupe": gn}) return tests, int(a["vmid"]) raise SystemExit(f"REFUS : « {hote} » n'a aucune affectation au devis de {instance}.") def jouer(instance: str, hote: str, tests: list[dict]) -> dict[str, str]: par_src: dict[str, list[dict]] = {} for t in tests: par_src.setdefault(t["src"], []).append(t) res: dict[str, str] = {} for src, xs in par_src.items(): lignes = [] for t in xs: cle = f"{t['src']}>{hote}:{t['proto']}/{t['port']}" essai = (f"ping -c1 -W2 {t['ip']} >/dev/null 2>&1" if t["proto"] == "icmp" else f"timeout 3 bash -c '/dev/null") lignes.append(f"{essai} && echo '{cle} ok' || echo '{cle} echec'") for sortie in _ansible(instance, src, "; ".join(lignes)).values(): for ligne in sortie.splitlines(): if ligne.endswith((" ok", " echec")): k, v = ligne.rsplit(" ", 1) res[k] = v return res def ecoutes(instance: str, hote: str) -> set[str]: """Ports ecoutes HORS de la boucle locale : ce qu'un voisin peut atteindre.""" sortie = _ansible(instance, hote, "ss -Hltn").get(hote, "") ports = set() for ligne in sortie.splitlines(): champs = ligne.split() if len(champs) >= 4: adr, _, port = champs[3].rpartition(":") if not adr.startswith(("127.", "[::1]")): ports.add(port) return ports def observer(instance: str, hote: str, tests: list[dict]) -> list[str]: """Les connexions entrantes etablies qu'AUCUNE regle ne couvre.""" cmd = ('L=" $(ss -Hltn | awk \'{n=split($4,a,":"); print a[n]}\' | sort -u | tr "\\n" " ") "; ' 'for i in 1 2 3; do ss -Hnt state established | awk -v L="$L" ' '\'{n=split($3,a,":"); lp=a[n]; m=split($4,b,":"); p=b[1]; for(k=2;k {hote}:{port}") return sorted(set(hors)) def activer(instance: str, vmid: int, args) -> int: distante = (f"cd {args.racine_runner}/Set-OPS-public && export PATH={args.racine_runner}/venv/bin:$PATH " f"&& SETOPS_INSTANCE={args.racine_runner}/{instance} make proxmox-fw-appliquer " f"CONFIRMER=true ARGS='--vm {vmid}'") cmd = ["ssh", "-o", "BatchMode=yes", "-J", args.rebond, args.runner, f"sudo -u setops bash -lc {json.dumps(distante)}"] r = subprocess.run(cmd, capture_output=True, text=True) for ligne in r.stdout.splitlines(): if re.search(r"~ VM|ECHEC|reconcilie|INCOMPLETE|Rien a faire", ligne): print(" ", ligne.strip()) return 0 if ("reconcilie" in r.stdout or "Rien a faire" in r.stdout) and "ECHEC" not in r.stdout else 1 def critiques_icinga(instance: str) -> list[str]: _ansible(instance, "all", "systemctl start setops-sante.service; true") cmd = ("P=$(sed -n '/ApiUser \"root\"/,/}/p' /etc/icinga2/conf.d/api-users.conf | " "grep -oP 'password = \"\\K[^\"]+'); " "curl -sk -u \"root:$P\" -X POST -H 'Accept: application/json' " "https://localhost:5665/v1/actions/reschedule-check " "-d '{\"type\":\"Service\",\"filter\":\"service.name==\\\"ping4\\\"\",\"force\":true}' >/dev/null; " "sleep 20; curl -sk -u \"root:$P\" " "'https://localhost:5665/v1/objects/services?filter=service.state==2&attrs=last_check_result'") sortie = next(iter(_ansible(instance, "serveur_icinga", cmd).values()), "{}") try: return [x["name"] for x in json.loads(sortie[sortie.index("{"):])["results"]] except (ValueError, KeyError): return ["(Icinga illisible)"] def main() -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--instance", required=True, help="le locataire, ex. OPS-Technolibre") ap.add_argument("--hote", required=True, help="la VM, ex. idm-01") mode = ap.add_mutually_exclusive_group(required=True) mode.add_argument("--plan", action="store_true", help="verifier, sans rien activer") mode.add_argument("--activer", action="store_true", help="verifier, activer, reverifier") ap.add_argument("--runner", default="ansible@10.37.31.11", help="runner du site (API du cluster)") ap.add_argument("--rebond", default="ansible@10.37.0.1", help="rebond vers le runner") ap.add_argument("--racine-runner", default="/opt/setops") args = ap.parse_args() I, H = args.instance, args.hote tests, vmid = matrice(I, H) print(f"=== {H} ({I}, VM {vmid}) — {len(tests)} test(s) tires du devis") hors = observer(I, H, tests) print(f" observation : {len(hors)} flux etabli(s) hors des regles") for h in hors: print(f" HORS : {h}") avant = jouer(I, H, tests) ecoute = ecoutes(I, H) def _sans_objet(cle: str) -> bool: proto, port = cle.rsplit(":", 1)[1].split("/", 1) return proto != "icmp" and port not in ecoute sans_objet = {k for k, v in avant.items() if v == "echec" and _sans_objet(k)} echecs = sorted(k for k, v in avant.items() if v == "echec" and k not in sans_objet) print(f" avant : {sum(v == 'ok' for v in avant.values())}/{len(avant)} ok" + (f", {len(sans_objet)} sans objet (rien n'ecoute hors de 127.0.0.1)" if sans_objet else "")) for k in sorted(sans_objet): print(f" sans objet : {k}") for k in echecs: print(f" DEJA EN ECHEC : {k}") if args.plan: print(" PLAN SEUL — rien n'a ete active.") return 1 if (hors or echecs) else 0 if hors or echecs: print(" REFUS D'ACTIVER : un flux reel hors des regles, ou un echec a comprendre d'abord.") return 2 if activer(I, vmid, args) != 0: print(" ECHEC de l'activation.") return 3 print(f" active a {time.strftime('%H:%M:%S')}") apres = jouer(I, H, tests) change = sorted(k for k in avant if avant[k] != apres.get(k)) print(f" apres : {sum(v == 'ok' for v in apres.values())}/{len(apres)} ok ; " f"changements : {', '.join(change) or 'aucun'}") crit = critiques_icinga(I) print(f" Icinga : {len(crit)} critique(s)" + (f" — {', '.join(crit)}" if crit else "")) if change: print(f" RETOUR ARRIERE : remettre `enable=0` sur la VM {vmid} " f"(/nodes//qemu/{vmid}/firewall/options).") return 4 if (change or crit) else 0 if __name__ == "__main__": sys.exit(main())