Set-OPS-Public/scripts/eprouver_parefeu.py
Daniel Allaire cc906af918 reconstruction : monter-flotte et parefeu-*-flotte entrent dans le code
La sequence du runner (flux, socle, deployer-tout, valider) devient
make monter-flotte ; reconstruire s'appuie dessus. eprouver_parefeu.py
--flotte : toutes les VM une a une, runner en dernier, arret au premier
refus ; les reseaux des IPSet (zone d'administration) comptent enfin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 05:23:27 -04:00

319 lines
15 KiB
Python

#!/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 <vmid>` ;
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, 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)
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 = []
# 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"]), reseaux
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/tcp/{t['ip']}/{t['port']}' 2>/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],
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" '
'\'{n=split($3,a,":"); lp=a[n]; m=split($4,b,":"); p=b[1]; for(k=2;k<m;k++) p=p":"b[k]; '
'if (index(L," " lp " ")) print p" "lp}\'; sleep 5; done | sort -u')
sortie = _ansible(instance, hote, cmd).get(hote, "")
ips = _ip_par_hote(instance)
h_par_ip = {ip: h for h, ip in ips.items()}
permis: dict[str, set[str]] = {}
for t in tests:
permis.setdefault(t["port"], set()).add(t["src"])
admin = [ipaddress.ip_network(n) for n in admin_avec_tunnel(instance)]
hors = []
for ligne in sortie.splitlines():
champs = ligne.split()
if len(champs) != 2:
continue
ip = champs[0].strip("[]").replace("::ffff:", "")
port = champs[1]
if ip.startswith("127.") or ip == "::1":
continue
src = h_par_ip.get(ip, ip)
if src == hote:
continue # vers soi-meme par sa propre adresse : `lo`, pas le pont Proxmox
if src in permis.get(port, set()):
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))
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 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")
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")
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()
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
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, reseaux)
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/<noeud>/qemu/{vmid}/firewall/options).")
return 4 if (change or crit) else 0
if __name__ == "__main__":
sys.exit(main())