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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-28 16:09:22 -04:00
parent d243773b45
commit 5e7534d87f
4 changed files with 311 additions and 0 deletions

View file

@ -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

View file

@ -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=<OPS-X> HOTE=<vm>
@if [ -z "$(TENANT)" ] || [ -z "$(HOTE)" ]; then printf '%s\n' 'Refus: TENANT=<OPS-X> et HOTE=<vm> 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=<OPS-X> et HOTE=<vm> 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

View file

@ -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."

264
scripts/eprouver_parefeu.py Normal file
View file

@ -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 <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_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/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]) -> 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_de(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
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 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/<noeud>/qemu/{vmid}/firewall/options).")
return 4 if (change or crit) else 0
if __name__ == "__main__":
sys.exit(main())