From 5e7534d87f289fca06b34cbe5bc61afa098e1e73 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 28 Sep 2026 16:09:22 -0400 Subject: [PATCH] pare-feu proxmox : la procedure d activation prouvee entre au depot eprouver_parefeu.py, make proxmox-fw-eprouver et proxmox-fw-activer-vm : matrice du devis, flux observes, avant/apres, Icinga ; sans objet derive de ce qui ecoute, VMID du bon locataire. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 22 +++ Makefile | 12 ++ docs/runbooks-construction.yml | 13 ++ scripts/eprouver_parefeu.py | 264 +++++++++++++++++++++++++++++++++ 4 files changed, 311 insertions(+) create mode 100644 scripts/eprouver_parefeu.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d1bdd12..cdda100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # CHANGELOG — Set-OPS +## 2026-09-28 (16) — La procédure d'activation du pare-feu Proxmox entre au dépôt + +`scripts/eprouver_parefeu.py`, par `make proxmox-fw-eprouver TENANT= HOTE=` (aucune écriture) +et `make proxmox-fw-activer-vm TENANT= HOTE= CONFIRMER=true`. Ce qui a servi à filtrer les 26 +VM, sous une forme qui resservira : une VM reconstruite perd ses options de pare-feu. + +Pour UNE VM : matrice tirée du devis (chaque membre de chaque source), connexions entrantes +établies confrontées aux règles, matrice avant, activation par le runner du site (seul à +joindre l'API du cluster), matrice après, sondes relancées, critiques d'Icinga. Refus +d'activer si un flux réel échappe aux règles ou si un flux échoue déjà. + +**Plus d'exclusions à la main** : un échec vers un port que rien n'écoute hors de +`127.0.0.1` est classé « sans objet » (nginx lié en local derrière la passerelle SSO, +frontal sans site) — c'est ce que la procédure manuelle tranchait au cas par cas. +**Le VMID se dérive du devis**, et du bloc du BON locataire : au premier essai, le script +visait `ops-01` de Chezlepro pour une demande sur Technolibre — les noms d'hôtes se +répètent d'un locataire à l'autre. + +Éprouvé : `--plan` sur `ops-01` (8090 reconnu sans objet), `--activer` de bout en bout sur +`web-frontal-01` (runner : « Rien à faire », après = avant, Icinga : 0 critique). +Limite écrite : un flux UDP est testé en TCP sur le même port. + ## 2026-09-28 (15) — Les 26 VM des locataires filtrées par Proxmox ; `proxmox-fw-plan` : « Rien à faire » **Chezlepro, 13/13**, dans le même ordre que Technolibre et par la même procédure, VM par diff --git a/Makefile b/Makefile index a97edc6..87ca05b 100644 --- a/Makefile +++ b/Makefile @@ -709,6 +709,18 @@ proxmox-fw-appliquer: ansible-runtime ## Reconcilie le pare-feu est-ouest : IPSe exit 2; fi CONFIRMER=true python3 scripts/appliquer_proxmox_fw.py $(ARGS) +proxmox-fw-eprouver: ansible-runtime ## Verifie qu'activer le pare-feu d'UNE VM ne coupe rien (aucune ecriture) — TENANT= HOTE= + @if [ -z "$(TENANT)" ] || [ -z "$(HOTE)" ]; then printf '%s\n' 'Refus: TENANT= et HOTE= requis.'; exit 2; fi + python3 scripts/eprouver_parefeu.py --instance "$(TENANT)" --hote "$(HOTE)" --plan + +proxmox-fw-activer-vm: ansible-runtime ## Active le pare-feu d'UNE VM en le prouvant avant/apres — TENANT= HOTE= CONFIRMER=true + @# UNE VM A LA FOIS, PREUVE COMPRISE (2026-09-28). Refuse si un flux reel echappe aux + @# regles ou si un flux echoue deja ; rejoue la matrice apres et lit Icinga. + @if [ -z "$(TENANT)" ] || [ -z "$(HOTE)" ]; then printf '%s\n' 'Refus: TENANT= et HOTE= requis.'; exit 2; fi + @if [[ "$(CONFIRMER)" != "true" ]]; then printf '%s\n' "Refus: active le pare-feu de $(HOTE) en REJECT. Voir d'abord 'make proxmox-fw-eprouver', puis CONFIRMER=true."; exit 2; fi + python3 scripts/eprouver_parefeu.py --instance "$(TENANT)" --hote "$(HOTE)" --activer + +.PHONY: proxmox-fw-eprouver proxmox-fw-activer-vm .PHONY: placement-plan sdn-plan sdn-appliquer placement-plan: ansible-runtime ## Le noeud, stockage, pont et gabarit du tenant existent-ils sur ce cluster ? (aucune ecriture) python3 scripts/devis_placement.py diff --git a/docs/runbooks-construction.yml b/docs/runbooks-construction.yml index b0db4d0..84fb23a 100644 --- a/docs/runbooks-construction.yml +++ b/docs/runbooks-construction.yml @@ -317,6 +317,19 @@ runbooks: nature: ecriture fixes: {CONFIRMER: "true"} pourquoi: "Poser IPSets, groupes et affectations — et retirer ce qui ne se declare plus." + - cible: proxmox-fw-eprouver + nature: mesure + variables: [TENANT, HOTE] + pourquoi: >- + Avant d'activer le pare-feu d'UNE VM : les regles du devis tiennent-elles face aux + flux que la VM recoit REELLEMENT ? Une VM reconstruite a perdu ses options. + - cible: proxmox-fw-activer-vm + nature: ecriture + variables: [TENANT, HOTE] + fixes: {CONFIRMER: "true"} + pourquoi: >- + Activer une VM a la fois, matrice avant et apres, Icinga lu. Chaque defaut du + 2026-09-28 ne s'est montre qu'a l'activation d'UNE VM. - cible: devis-opnsense nature: mesure pourquoi: "La frontiere nord/sud, derivee du meme registre de flux." diff --git a/scripts/eprouver_parefeu.py b/scripts/eprouver_parefeu.py new file mode 100644 index 0000000..0470fce --- /dev/null +++ b/scripts/eprouver_parefeu.py @@ -0,0 +1,264 @@ +#!/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_de # 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())