diff --git a/CHANGELOG.md b/CHANGELOG.md index 28346b0..ee8e829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # CHANGELOG — Set-OPS +## 2026-09-30 (51) — Ce qui était fait à la main dans les reconstructions entre dans le code + +**Le bilan de l'exploitant** : aucune des deux reconstructions du jour n'est allée au bout +seule. Au-delà des défauts corrigés, trois gestes vivaient hors du dépôt : + +1. **La séquence du runner du locataire** était un script écrit dans `/tmp` du runner. + Elle devient `make monter-flotte CONFIRMER=true` : `flux`, AC et DNS (`_amorcer-socle`), + `deployer-tout`, `valider` — étapes horodatées, arrêt à la première en échec. + `reconstruire` s'appuie désormais dessus (et gagne `valider`, qu'il n'avait pas). +2. **La réactivation du pare-feu Proxmox** était une boucle tapée à la main. + `eprouver_parefeu.py --flotte` porte la procédure pour toutes les VM, **une à une, le + runner en dernier, arrêt au premier refus** — la règle qui la rend sûre vit avec elle. + Cibles `parefeu-verifier-flotte` (mesure) et `parefeu-activer-flotte` (depuis le poste : + lui seul tient à la fois les accès du locataire et le runner du site). +3. **La sauvegarde fraîche avant de raser** reste une ÉTAPE du runbook `flotte-refaire`, + pas une condition de `raser` : décision de l'exploitant, « une confirmation suffit ». + +**`--flotte` a trouvé un défaut dès sa première passe** (lecture seule, Technolibre) : il +s'est arrêté sur `infra-edge-01`, un flux `10.37.0.17 → 443` « hors des règles ». C'était le +navigateur de l'exploitant, depuis la zone d'administration du site — légitime, et +effectivement admis par Proxmox. La matrice ne retenait des IPSet que les membres qui sont +des MACHINES ; les RÉSEAUX (`t23-admin` : `10.37.0.0/24`…) étaient ignorés, et l'observateur +ne connaissait l'administration que sur le port 22. Les réseaux de chaque règle (exclusions +`!` comprises) comptent désormais sur tous les ports. Seconde passe : **13/13, runner en +dernier, aucun flux perdu**. Ce faux refus aurait bloqué une reconstruction le jour où +l'exploitant a un onglet ouvert sur ses services — c'est-à-dire presque toujours. + ## 2026-09-30 (50) — Le web frontal n'est plus un détenteur d'état Le frontal RELAIE : ses vhosts, son WAF et sa page 404 se redéploient depuis le dépôt. Le diff --git a/Makefile b/Makefile index d67a884..c924e6c 100644 --- a/Makefile +++ b/Makefile @@ -1423,8 +1423,49 @@ reconstruire: _instance-requise ## Reconstruit un ecosysteme depuis zero : VM pu $(MAKE) flux; \ $(MAKE) flotte-creer CONFIRMER=true; \ $(MAKE) _attendre-flotte; \ - $(MAKE) _amorcer-socle; \ - $(MAKE) deployer-tout CONFIRMER=true + $(MAKE) monter-flotte CONFIRMER=true + +# MONTER UNE FLOTTE DONT LES VM EXISTENT DEJA (2026-09-30). C'est la seconde moitie de +# `reconstruire`, et TOUT ce que le runner d'un locataire peut en faire : il ne cree pas de VM +# (l'API du cluster est a l'hebergeur), il les recoit, armees, et les monte. Jusqu'ici cette +# sequence vivait dans un script ecrit a la main dans /tmp du runner — deux reconstructions +# l'ont portee, et aucune trace ne la gardait. +# +# `set -e` : la premiere etape en echec arrete tout. Chaque etape est horodatee, pour qu'un +# journal dise ou l'on en etait sans relire dix mille lignes d'Ansible. `valider` fait partie +# de la sequence : une flotte sans recette n'est pas montee, elle est deployee. +.PHONY: monter-flotte +monter-flotte: _instance-requise ## Monte une flotte deja creee : flux, AC et DNS, tout le reste, recette — CONFIRMER=true + @set -e; \ + if [[ "$(CONFIRMER)" != "true" ]]; then \ + printf '%s\n' 'Refus: MONTER la flotte (deploiement complet puis recette). Relancer avec CONFIRMER=true.'; \ + exit 2; \ + fi; \ + etape() { printf '\n=== ETAPE %s %s\n' "$$1" "$$(date +%T)"; }; \ + etape flux; $(MAKE) flux; \ + etape _amorcer-socle; $(MAKE) _amorcer-socle; \ + etape deployer-tout; $(MAKE) deployer-tout CONFIRMER=true; \ + etape valider; $(MAKE) valider; \ + printf '\n=== FLOTTE MONTEE %s\n' "$$(date +%T)" + +# LE PARE-FEU PROXMOX D'UNE FLOTTE RECONSTRUITE (2026-09-30). Une VM clonee nait sans les +# options de pare-feu : chaque reconstruction les perd. La procedure (une VM a la fois, +# matrice avant/apres, Icinga) etait rejouee par une boucle tapee a la main ; `--flotte` la +# porte, avec la regle qui la rend sure : arret au premier refus. +# +# DEPUIS LE POSTE : la matrice se joue avec les acces du LOCATAIRE, l'activation passe par +# le runner du SITE, seul a joindre l'API du cluster. Le poste est le seul a tenir les deux. +.PHONY: parefeu-verifier-flotte parefeu-activer-flotte +parefeu-verifier-flotte: ## Verifie, sans rien activer, ce que le pare-feu Proxmox couperait sur chaque VM — INSTANCE=OPS-X + @if [[ -z "$(INSTANCE)" ]]; then printf '%s\n' 'Refus: INSTANCE= requis.'; exit 2; fi + python3 scripts/eprouver_parefeu.py --instance "$(INSTANCE)" --flotte --plan + +parefeu-activer-flotte: ## Active le pare-feu Proxmox de chaque VM, une a une, arret au premier ecart — INSTANCE=OPS-X CONFIRMER=true + @if [[ -z "$(INSTANCE)" || "$(CONFIRMER)" != "true" ]]; then \ + printf '%s\n' 'Refus: INSTANCE= et CONFIRMER=true requis (active le pare-feu de TOUTES les VM, une a une).'; \ + exit 2; \ + fi + python3 scripts/eprouver_parefeu.py --instance "$(INSTANCE)" --flotte --activer # « Go ahead, make my day. » — LE bouton rouge : alias de reconstruire (Clint Eastwood). # Cree toutes les VM puis deploie tout, en une commande. Garde CONFIRMER=true. diff --git a/docs/runbooks-construction.yml b/docs/runbooks-construction.yml index ae9d695..b7714eb 100644 --- a/docs/runbooks-construction.yml +++ b/docs/runbooks-construction.yml @@ -382,6 +382,14 @@ runbooks: pourquoi: >- Les VM manquantes, clonees depuis le gabarit dore. VMID, IP et VLAN sont DERIVES du plan : on ne les saisit nulle part. + - cible: monter-flotte + nature: ecriture + duree: "long" + fixes: {CONFIRMER: "true"} + pourquoi: >- + Des VM qui viennent de naitre (ou de renaitre) a la flotte recettee : flux, AC et + DNS d'abord, tout le reste, puis `valider`. C'est la sequence du runner apres une + reconstruction ; chaque proprietaire d'etat y remet celui d'avant. - cible: deployer-tout nature: ecriture duree: "long" @@ -498,6 +506,21 @@ runbooks: - cible: valider nature: mesure pourquoi: "Une reconstruction sans recette n'a rien prouve." + - cible: parefeu-verifier-flotte + nature: mesure + portee: poste + variables: [INSTANCE] + pourquoi: >- + Ce que le pare-feu Proxmox couperait, VM par VM, avant de l'activer : une VM + clonee nait sans ses options de pare-feu. + - cible: parefeu-activer-flotte + nature: ecriture + portee: poste + variables: [INSTANCE] + fixes: {CONFIRMER: "true"} + pourquoi: >- + L'activer une VM a la fois, matrice avant et apres, Icinga ; arret au premier + ecart, le runner en dernier. # ───────────────────────────────────────────────────────────────────────────── - id: gabarit diff --git a/scripts/eprouver_parefeu.py b/scripts/eprouver_parefeu.py index 4cb1c3c..7ff4bb8 100644 --- a/scripts/eprouver_parefeu.py +++ b/scripts/eprouver_parefeu.py @@ -80,7 +80,7 @@ def _ansible(instance: str, hotes: str, commande: str) -> dict[str, str]: return sorties -def matrice(instance: str, hote: str) -> tuple[list[dict], int]: +def matrice(instance: str, hote: str) -> tuple[list[dict], int, dict[str, list]]: 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) @@ -98,16 +98,29 @@ def matrice(instance: str, hote: str) -> tuple[list[dict], int]: continue groupes = {g["nom"]: g for g in b["groupes"]} tests = [] + # LES MEMBRES QUI SONT DES RESEAUX, PAS DES MACHINES (2026-09-30). On ne peut pas les + # TESTER (aucune machine a qui demander d'ouvrir la connexion), mais Proxmox les + # applique : `t23-admin` porte la zone d'administration du site. Les ignorer faisait + # prendre le navigateur de l'exploitant, sur l'edge en 443, pour un flux « hors des + # regles » — et `--flotte` s'arretait. `!reseau` = exclusion (`nomatch`). + reseaux: dict[str, list] = {} for gn in a["groupes"]: for regle in groupes[gn]["regles"]: membres = b["ipsets"].get(regle["source"].lstrip("+"), {}).get("membres", []) + port = str(regle.get("dport") or regle.get("icmp_type")) + pos = [m for m in membres if "/" in str(m) and not str(m).startswith("!")] + neg = [str(m)[1:] for m in membres if str(m).startswith("!")] + if pos: + reseaux.setdefault(port, []).append( + ([ipaddress.ip_network(x, strict=False) for x in pos], + [ipaddress.ip_network(x, strict=False) for x in neg])) 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"]) + return tests, int(a["vmid"]), reseaux raise SystemExit(f"REFUS : « {hote} » n'a aucune affectation au devis de {instance}.") @@ -144,7 +157,8 @@ def ecoutes(instance: str, hote: str) -> set[str]: return ports -def observer(instance: str, hote: str, tests: list[dict]) -> list[str]: +def observer(instance: str, hote: str, tests: list[dict], + reseaux: dict[str, list] | None = None) -> 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" ' @@ -173,6 +187,10 @@ def observer(instance: str, hote: str, tests: list[dict]) -> list[str]: continue if port == "22" and any(ipaddress.ip_address(ip) in n for n in admin): continue + adr = ipaddress.ip_address(ip) + if any(any(adr in n for n in pos) and not any(adr in n for n in neg) + for pos, neg in (reseaux or {}).get(port, [])): + continue hors.append(f"{src} -> {hote}:{port}") return sorted(set(hors)) @@ -206,10 +224,30 @@ def critiques_icinga(instance: str) -> list[str]: return ["(Icinga illisible)"] +def ordre_flotte(instance: str) -> list[str]: + """Toutes les VM du locataire, son RUNNER EN DERNIER. + + C'est depuis le poste qu'on joue la matrice, mais c'est le runner qui conduit le reste + de la vie du locataire : s'il devait etre coupe, qu'il le soit apres que tout le reste + a ete prouve — et qu'on le voie seul en cause. + """ + import yaml + enfants = (yaml.safe_load(_inventaire(instance).read_text(encoding="utf-8")) + .get("all", {}).get("children", {})) + runners = set() + for g in ("serveur_ops_tenant", "serveur_ops"): + runners |= set(((enfants.get(g) or {}).get("hosts") or {})) + hotes = sorted(_ip_par_hote(instance)) + return [h for h in hotes if h not in runners] + [h for h in hotes if h in runners] + + 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") + cible = ap.add_mutually_exclusive_group(required=True) + cible.add_argument("--hote", help="la VM, ex. idm-01") + cible.add_argument("--flotte", action="store_true", + help="toutes les VM, une a une, le runner en dernier ; arret au premier refus") 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") @@ -217,11 +255,28 @@ def main() -> int: 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 + if args.hote: + return eprouver(args.instance, args.hote, args) + # UNE A UNE, ET ON S'ARRETE AU PREMIER REFUS (2026-09-30). C'etait une boucle tapee a + # la main apres chaque reconstruction ; la regle qui la rend sure — ne pas continuer + # apres un ecart — doit vivre avec elle, pas dans la memoire de celui qui la tape. + hotes = ordre_flotte(args.instance) + for n, h in enumerate(hotes, 1): + rc = eprouver(args.instance, h, args) + if rc != 0: + print(f"\nARRET sur {h} (code {rc}) : {n - 1}/{len(hotes)} VM traitees avant lui. " + "Rien d'autre n'a ete touche.") + return rc + print(f"\n{len(hotes)}/{len(hotes)} VM : pare-feu Proxmox " + + ("verifiable" if args.plan else "actif") + ", aucun flux perdu.") + return 0 - tests, vmid = matrice(I, H) + +def eprouver(I: str, H: str, args: argparse.Namespace) -> int: + """La procedure pour UNE VM (voir l'en-tete). 0 = conforme.""" + tests, vmid, reseaux = matrice(I, H) print(f"=== {H} ({I}, VM {vmid}) — {len(tests)} test(s) tires du devis") - hors = observer(I, H, tests) + hors = observer(I, H, tests, reseaux) print(f" observation : {len(hors)} flux etabli(s) hors des regles") for h in hors: print(f" HORS : {h}")