Le type ICMP (echo-request) etait range parmi les ports derives et le flux saute : le ping de supervision n avait pas de regle, et web-frontal-01 passe en REJECT est devenu mort pour Icinga. icmp_type dans le devis, icmp-type vers l API. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
376 lines
19 KiB
Python
376 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""Reconcilie le pare-feu Proxmox (est-ouest intra-tenant) avec son devis.
|
|
|
|
Trois couches, toutes au niveau du CLUSTER sauf la derniere :
|
|
- IPSets : qui porte quel role
|
|
- Groupes : les flux entrants d'un role
|
|
- Affectations : quels groupes sur quelle VM
|
|
|
|
DEUX SENS : ce que le devis demande et qui manque est cree, ce qu'il ne demande
|
|
plus est RETIRE. Un groupe orphelin ne bloque rien, mais il decrit une politique
|
|
qui n'est plus la notre.
|
|
|
|
LE REFUS EST PORTE PAR LA VM, jamais par le datacenter. Au datacenter, `policy_in`
|
|
est la politique par defaut de TOUTE VM dont le pare-feu s'active — un parc herite
|
|
sans regles tomberait d'un bloc. Pose sur chaque VM tenant, il donne la meme
|
|
isolation sans cette falaise, et le script le fait lui-meme.
|
|
|
|
Il n'active pas le pare-feu du datacenter (`enable=1`) : c'est un reglage de
|
|
cluster, decide une fois, et signale tant qu'il manque.
|
|
|
|
PERIMETRE STRICT : seuls les objets prefixes par l'etiquette d'un tenant du devis
|
|
(`t11-`, `t17-`) sont touches. Le reste du cluster n'existe pas pour ce script.
|
|
|
|
NON DESTRUCTIF PAR DEFAUT : sans `CONFIRMER=true`, aucune ecriture (regle 4).
|
|
|
|
Usage :
|
|
python3 scripts/appliquer_proxmox_fw.py # plan seul
|
|
CONFIRMER=true python3 scripts/appliquer_proxmox_fw.py # applique
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
# LA POLITIQUE DE REFUS DE L'EST-OUEST — UNE SEULE SOURCE (2026-08-28).
|
|
#
|
|
# Elle etait ecrite DEUX FOIS : une a l'ecriture, une a la detection d'ecart. Deux
|
|
# litteraux pour la meme decision, et le jour ou l'un change sans l'autre, le devis
|
|
# propose une mise a jour eternelle qu'il vient pourtant d'appliquer.
|
|
#
|
|
# REJECT ET NON DROP : cette couche filtre l'interne. Voir la preuve P53 et
|
|
# `docs/flux-conception.md` — l'interne parle, la bordure se tait.
|
|
POLITIQUE_VM = "REJECT"
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
RACINE = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(RACINE / "scripts"))
|
|
|
|
from proxmox_api import Cluster # noqa: E402
|
|
|
|
|
|
def _prefixe(bloc: dict) -> str:
|
|
"""`t11-` — deduit du premier IPSet, qui les porte tous. Jamais saisi."""
|
|
for nom in bloc.get("ipsets") or {}:
|
|
return nom.split("-")[0] + "-"
|
|
for g in bloc.get("groupes") or []:
|
|
return str(g["nom"]).split("-")[0] + "-"
|
|
return ""
|
|
|
|
|
|
def _norm(cidr: str) -> str:
|
|
"""Forme comparable d'un membre d'IPSet.
|
|
|
|
Proxmox RANGE `192.168.255.2/32` sous la forme `192.168.255.2`. Comparer les deux
|
|
litteralement laisse un ecart qui ne se referme jamais : chaque passage croit devoir
|
|
corriger, et la reconciliation ne converge plus.
|
|
"""
|
|
c = str(cidr).strip()
|
|
for suffixe in ("/32", "/128"):
|
|
if c.endswith(suffixe):
|
|
return c[: -len(suffixe)]
|
|
return c
|
|
|
|
|
|
def _regle_cle(r: dict) -> tuple:
|
|
"""Identite d'une regle de groupe, insensible a la position et au commentaire."""
|
|
return (str(r.get("type") or r.get("sens") or "").lower(),
|
|
str(r.get("action") or "").upper(),
|
|
str(r.get("source") or ""),
|
|
str(r.get("proto") or ""),
|
|
str(r.get("dport") or ""),
|
|
# Le devis dit `icmp_type`, l'API de Proxmox rend `icmp-type`.
|
|
str(r.get("icmp_type") or r.get("icmp-type") or ""))
|
|
|
|
|
|
def _prefixes_retires() -> tuple[str, ...]:
|
|
"""Les etiquettes des tenants RETIRES : `t29-` pour la zone `t29`.
|
|
|
|
UN TENANT RETIRE SORT DU PERIMETRE, ET SES OBJETS AVEC (2026-09-28). Le perimetre ne
|
|
retient que les prefixes du devis ; un index libere n'y est plus, donc ses IPSets et
|
|
ses groupes n'existaient plus pour ce script — ni a creer, ni a retirer. Ils restaient
|
|
poses. La liste n'est pas ecrite ici : c'est celle du SDN (`ANCIEN_NOMMAGE`), dont les
|
|
zones `t<index>` portent la meme etiquette que les IPSets. Une liste, pas deux.
|
|
"""
|
|
import devis_sdn
|
|
return tuple(f"{z}-" for z in devis_sdn.ANCIEN_NOMMAGE
|
|
if z.startswith("t") and z[1:].isdigit())
|
|
|
|
|
|
def _lire(api: Cluster, chemin: str):
|
|
"""Une lecture du cluster, ou l'arret : un cluster muet n'est pas un cluster vide.
|
|
|
|
Meme defaut, meme correctif que `appliquer_sdn._lire` (2026-09-28) : `rep or []`
|
|
parcourait le dict d'erreur comme une liste vide, et le plan proposait de tout creer.
|
|
"""
|
|
rep = api(chemin)
|
|
if api.rate(rep):
|
|
raise SystemExit(f"Le cluster {api.hote} ne repond pas sur `{chemin}` : "
|
|
f"{api.rate(rep)}\n (un cluster injoignable serait lu comme un "
|
|
f"cluster VIDE, et le plan proposerait de tout recreer)")
|
|
return rep
|
|
|
|
|
|
def plan(api: Cluster, devis: dict) -> dict:
|
|
prefixes = tuple(_prefixe(b) for b in devis["blocs"] if _prefixe(b)) + _prefixes_retires()
|
|
|
|
ipsets_voulus, groupes_voulus, affect_voulues = {}, {}, {}
|
|
for b in devis["blocs"]:
|
|
for nom, d in (b.get("ipsets") or {}).items():
|
|
ipsets_voulus[nom] = {"membres": sorted({_norm(m) for m in d["membres"]}),
|
|
"role": d.get("role", "")}
|
|
for g in b.get("groupes") or []:
|
|
groupes_voulus[g["nom"]] = g
|
|
for a in b.get("affectations") or []:
|
|
affect_voulues[int(a["vmid"])] = {"hote": a["hote"], "groupes": sorted(a["groupes"])}
|
|
|
|
# --- etat du cluster, restreint a nos prefixes -----------------------------
|
|
ipsets_poses = {}
|
|
for s in (_lire(api, "/cluster/firewall/ipset") or []):
|
|
if isinstance(s, dict) and str(s.get("name", "")).startswith(prefixes):
|
|
membres = [str(m.get("cidr")) for m in (api(f"/cluster/firewall/ipset/{s['name']}") or [])
|
|
if isinstance(m, dict)]
|
|
ipsets_poses[s["name"]] = sorted({_norm(m) for m in membres})
|
|
|
|
groupes_poses = {}
|
|
for g in (_lire(api, "/cluster/firewall/groups") or []):
|
|
if isinstance(g, dict) and str(g.get("group", "")).startswith(prefixes):
|
|
regles = [r for r in (api(f"/cluster/firewall/groups/{g['group']}") or [])
|
|
if isinstance(r, dict)]
|
|
groupes_poses[g["group"]] = regles
|
|
|
|
vm_du_cluster = {int(r["vmid"]): r["node"] for r in (_lire(api, "/cluster/resources?type=vm") or [])
|
|
if isinstance(r, dict) and r.get("vmid")}
|
|
|
|
affect_posees, vm_absentes = {}, []
|
|
for vmid in affect_voulues:
|
|
noeud = vm_du_cluster.get(vmid)
|
|
if not noeud:
|
|
vm_absentes.append(vmid)
|
|
continue
|
|
regles = api(f"/nodes/{noeud}/qemu/{vmid}/firewall/rules") or []
|
|
o = api(f"/nodes/{noeud}/qemu/{vmid}/firewall/options") or {}
|
|
affect_posees[vmid] = {
|
|
"noeud": noeud,
|
|
"groupes": sorted(str(r.get("action")) for r in regles
|
|
if isinstance(r, dict) and r.get("type") == "group"),
|
|
"actif": str(o.get("enable", "")),
|
|
# Le refus vit ICI, sur la VM du tenant — pas au datacenter, ou il vaudrait
|
|
# pour tout le parc, y compris ce qui n'appartient a aucun tenant.
|
|
"politique": str(o.get("policy_in", "")).upper(),
|
|
}
|
|
|
|
opts = api("/cluster/firewall/options") or {}
|
|
return {
|
|
"ipsets_creer": {n: v for n, v in ipsets_voulus.items() if n not in ipsets_poses},
|
|
"ipsets_majer": {n: (ipsets_poses[n], v["membres"]) for n, v in ipsets_voulus.items()
|
|
if n in ipsets_poses and ipsets_poses[n] != v["membres"]},
|
|
"ipsets_retirer": sorted(n for n in ipsets_poses if n not in ipsets_voulus),
|
|
"groupes_creer": {n: g for n, g in groupes_voulus.items() if n not in groupes_poses},
|
|
"groupes_majer": {n: g for n, g in groupes_voulus.items()
|
|
if n in groupes_poses
|
|
and {_regle_cle(r) for r in g["regles"]}
|
|
!= {_regle_cle(r) for r in groupes_poses[n]}},
|
|
"groupes_retirer": sorted(n for n in groupes_poses if n not in groupes_voulus),
|
|
"affect_majer": {v: a for v, a in affect_voulues.items()
|
|
if v in affect_posees
|
|
and (affect_posees[v]["groupes"] != a["groupes"]
|
|
or affect_posees[v]["actif"] != "1"
|
|
or affect_posees[v]["politique"] != POLITIQUE_VM)},
|
|
"affect_posees": affect_posees,
|
|
"vm_absentes": sorted(vm_absentes),
|
|
"datacenter": {"actif": str(opts.get("enable", "")), "politique": str(opts.get("policy_in", ""))},
|
|
"_voulus": {"ipsets": ipsets_voulus, "groupes": groupes_voulus, "affect": affect_voulues},
|
|
}
|
|
|
|
|
|
def afficher(p: dict) -> bool:
|
|
for n in sorted(p["ipsets_creer"]):
|
|
print(f" + IPSet {n:<22} {len(p['ipsets_creer'][n]['membres'])} membre(s)")
|
|
for n, (avant, apres) in sorted(p["ipsets_majer"].items()):
|
|
print(f" ~ IPSet {n:<22} {len(avant)} -> {len(apres)} membre(s)")
|
|
for n in p["ipsets_retirer"]:
|
|
print(f" - IPSet PERIME {n}")
|
|
for n, g in sorted(p["groupes_creer"].items()):
|
|
print(f" + groupe {n:<22} {len(g['regles'])} regle(s) # {g.get('role','')}")
|
|
for n in sorted(p["groupes_majer"]):
|
|
print(f" ~ groupe {n:<22} regles differentes")
|
|
for n in p["groupes_retirer"]:
|
|
print(f" - groupe PERIME {n}")
|
|
for v, a in sorted(p["affect_majer"].items()):
|
|
print(f" ~ VM {v:<12} {a['hote']:<16} -> {', '.join(a['groupes'])}")
|
|
if p["vm_absentes"]:
|
|
print(f"\n {len(p['vm_absentes'])} VM du devis absente(s) du cluster — affectation "
|
|
f"differee, elle se posera au clonage :")
|
|
print(f" {', '.join(str(v) for v in p['vm_absentes'][:8])}"
|
|
+ (" …" if len(p["vm_absentes"]) > 8 else ""))
|
|
dc = p["datacenter"]
|
|
if dc["actif"] != "1":
|
|
print(f"\n /!\\ PREREQUIS NON REMPLI — pare-feu du datacenter desactive "
|
|
f"(enable={dc['actif'] or '0'}).")
|
|
print(" Sans lui, RIEN ne filtre : les objets ci-dessus restent inertes.")
|
|
print(" `enable=1` seul ne ferme rien — une VM n'est filtree que si SON propre")
|
|
print(" `enable` vaut 1 (defaut 0) et que sa carte porte `firewall=1`.")
|
|
print(" Ce script ne le bascule pas : c'est un reglage de cluster, decide une fois.")
|
|
if dc["politique"].upper() in ("DROP", "REJECT"):
|
|
print(f"\n /!\\ DANGER — `policy_in={dc['politique']}` AU DATACENTER.")
|
|
print(" C'est la politique par defaut de TOUTE VM dont le pare-feu s'active,")
|
|
print(" y compris ce qui n'appartient a aucun tenant. Set-OPS pose le DROP")
|
|
print(" PAR VM tenant : cette valeur au datacenter n'est pas requise, et expose")
|
|
print(" le reste du parc. La remettre a ACCEPT.")
|
|
creer = len(p["ipsets_creer"]) + len(p["groupes_creer"])
|
|
majer = len(p["ipsets_majer"]) + len(p["groupes_majer"]) + len(p["affect_majer"])
|
|
retirer = len(p["ipsets_retirer"]) + len(p["groupes_retirer"])
|
|
print(f"\n a creer : {creer} | a mettre a jour : {majer} | a retirer : {retirer}")
|
|
return bool(creer or majer or retirer)
|
|
|
|
|
|
def appliquer(api: Cluster, p: dict) -> int:
|
|
echecs = 0
|
|
|
|
def _fait(rep, quoi):
|
|
nonlocal echecs
|
|
if (e := api.rate(rep)):
|
|
echecs += 1
|
|
print(f" ! ECHEC {quoi} : {e[:150]}")
|
|
return False
|
|
return True
|
|
|
|
# 1. IPSets AVANT les groupes : une regle qui reference un IPSet absent est refusee.
|
|
for n, v in sorted(p["ipsets_creer"].items()):
|
|
if _fait(api("/cluster/firewall/ipset", "POST",
|
|
{"name": n, "comment": v["role"][:100]}), f"ipset {n}"):
|
|
for m in v["membres"]:
|
|
_fait(api(f"/cluster/firewall/ipset/{n}", "POST", {"cidr": m}), f"{n} += {m}")
|
|
for n, (avant, apres) in sorted(p["ipsets_majer"].items()):
|
|
for m in sorted(set(apres) - set(avant)):
|
|
_fait(api(f"/cluster/firewall/ipset/{n}", "POST", {"cidr": m}), f"{n} += {m}")
|
|
for m in sorted(set(avant) - set(apres)):
|
|
_fait(api(f"/cluster/firewall/ipset/{n}/{m}", "DELETE"), f"{n} -= {m}")
|
|
|
|
# 2. Groupes. Une mise a jour REMPLACE les regles : elles sont a nous, et les
|
|
# comparer une a une pour un edit partiel couterait plus que de les reposer.
|
|
for n in sorted(p["groupes_majer"]):
|
|
for r in reversed(api(f"/cluster/firewall/groups/{n}") or []):
|
|
_fait(api(f"/cluster/firewall/groups/{n}/{r['pos']}", "DELETE"), f"purge {n}")
|
|
for n, g in sorted({**p["groupes_creer"], **p["groupes_majer"]}.items()):
|
|
if n in p["groupes_creer"]:
|
|
_fait(api("/cluster/firewall/groups", "POST",
|
|
{"group": n, "comment": str(g.get("role", ""))[:100]}), f"groupe {n}")
|
|
for r in g["regles"]:
|
|
corps = {"type": str(r.get("sens", "in")).lower(), "action": r["action"],
|
|
"enable": 1, "comment": str(r.get("via") or g.get("role", ""))[:100]}
|
|
for cle, champ in (("source", "source"), ("proto", "proto"), ("dport", "dport"),
|
|
("icmp_type", "icmp-type")):
|
|
if r.get(cle):
|
|
corps[champ] = r[cle]
|
|
_fait(api(f"/cluster/firewall/groups/{n}", "POST", corps), f"regle de {n}")
|
|
|
|
# 3. Affectations aux VM, et activation de leur pare-feu — sans quoi les groupes
|
|
# seraient poses sur une machine qui ne filtre pas.
|
|
for v, a in sorted(p["affect_majer"].items()):
|
|
noeud = p["affect_posees"][v]["noeud"]
|
|
for r in reversed(api(f"/nodes/{noeud}/qemu/{v}/firewall/rules") or []):
|
|
if isinstance(r, dict) and r.get("type") == "group":
|
|
_fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/rules/{r['pos']}", "DELETE"),
|
|
f"purge VM {v}")
|
|
for g in a["groupes"]:
|
|
_fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/rules", "POST",
|
|
{"type": "group", "action": g, "enable": 1}), f"VM {v} += {g}")
|
|
# `policy_in` sur LA VM : c'est ce qui rend les groupes ci-dessus exclusifs.
|
|
# Au datacenter, la meme valeur s'appliquerait a tout ce qui n'est pas un tenant.
|
|
#
|
|
# REJECT ET NON DROP, PARCE QUE C'EST L'INTERNE (2026-08-28). Cette couche filtre
|
|
# l'est-ouest : des machines d'un meme ecosysteme, ou d'un tenant a son site. Le
|
|
# silence y coutait cher — un timeout voulait dire aussi bien « aucune machine »
|
|
# que « une politique », et le 2026-08-28 cette ambiguite a fait appliquer un
|
|
# pare-feu pour reparer une ABSENCE. Ce qui informe n'est pas le refus, c'est ce
|
|
# qu'il fait au TIMEOUT : quand la politique parle, le silence ne veut plus dire
|
|
# qu'une chose.
|
|
#
|
|
# L'OBSCURITE ETAIT DEJA NULLE ICI : chaque machine porte un `/etc/hosts` genere
|
|
# qui liste toutes ses voisines avec leurs adresses. Se cacher de pairs qui ont
|
|
# deja notre adresse n'achete rien.
|
|
#
|
|
# LA FRONTIERE, ELLE, RESTE EN `block` — et c'est ce qui rend ce reject prudent :
|
|
# rien d'indeclare ne franchit le perimetre, donc il ne repond jamais a
|
|
# l'Internet. Voir la preuve P53.
|
|
_fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/options", "PUT",
|
|
{"enable": 1, "policy_in": POLITIQUE_VM}), f"pare-feu de la VM {v}")
|
|
|
|
# 4. Retraits : groupes avant IPSets, un IPSet reference ne se supprime pas.
|
|
for n in p["groupes_retirer"]:
|
|
for r in reversed(api(f"/cluster/firewall/groups/{n}") or []):
|
|
_fait(api(f"/cluster/firewall/groups/{n}/{r['pos']}", "DELETE"), f"purge {n}")
|
|
_fait(api(f"/cluster/firewall/groups/{n}", "DELETE"), f"retrait groupe {n}")
|
|
# `force` DANS L'URL (2026-09-28) : Proxmox refuse tout corps sur un DELETE
|
|
# (« 501 Unexpected content for method 'DELETE' »). Envoye en corps, aucun IPSet
|
|
# n'etait jamais retire — constate en retirant ceux de `t29-`.
|
|
for n in p["ipsets_retirer"]:
|
|
_fait(api(f"/cluster/firewall/ipset/{n}?force=1", "DELETE"), f"retrait ipset {n}")
|
|
|
|
if echecs:
|
|
print(f"\n {echecs} echec(s) — la reconciliation est INCOMPLETE.")
|
|
return 1
|
|
print("\n Pare-feu est-ouest reconcilie.")
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
api, base = Cluster.depuis_hebergeur()
|
|
# NE PAS FORCER SETOPS_INSTANCE VERS L'HEBERGEUR (2026-08-22). Le devis a besoin d'un
|
|
# TENANT — il lit son inventaire pour resoudre les flux — et l'hebergeur n'en est pas
|
|
# un : depuis que le monde physique a son propre depot (SITE-<nom>), il ne porte ni
|
|
# plan ni inventaire. Ce qui vient de l'hebergeur (underlay, proxmox-hebergeur, voute)
|
|
# est DERIVE du symlink `underlay.yml`, jamais de cette variable.
|
|
#
|
|
# L'hypothese « le depot de l'hebergeur est aussi une instance » n'a tenu que tant que
|
|
# les deux etaient confondus. La separation l'a revelee, ce qui est son interet.
|
|
env_devis = {k: v for k, v in os.environ.items() if k != "SETOPS_INVENTAIRE"}
|
|
r = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_proxmox_fw.py"), "--json"],
|
|
cwd=RACINE, capture_output=True, text=True, env=env_devis)
|
|
if r.returncode != 0:
|
|
raise SystemExit("Le devis ne se genere pas :\n" + r.stderr.strip()[:400])
|
|
devis = json.loads(r.stdout)
|
|
|
|
# PAR ETAPES, ET NON D'UN BLOC (2026-09-28). Les objets (IPSets, groupes) sont INERTES
|
|
# tant qu'aucune VM ne les porte ; l'affectation, elle, active le pare-feu de la VM en
|
|
# `REJECT` — le filtrage demarre, et une regle manquante coupe un service. Mesure du
|
|
# jour : aucune des 26 VM des locataires n'avait son pare-feu actif (les reconstructions
|
|
# clonent sans les options). Tout activer d'un coup, c'etait ouvrir 26 pannes possibles
|
|
# en meme temps. On pose donc les objets, puis on active VM par VM.
|
|
# --objets-seulement IPSets et groupes, aucune VM touchee
|
|
# --vm <vmid> n'affecter QUE ces VM (repetable) ; les objets suivent
|
|
import argparse
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("--objets-seulement", action="store_true")
|
|
ap.add_argument("--vm", action="append", type=int, default=[])
|
|
args = ap.parse_args()
|
|
if args.objets_seulement and args.vm:
|
|
raise SystemExit("REFUS : --objets-seulement et --vm s'excluent.")
|
|
|
|
print(f"Cluster {api.hote} — {len(devis['blocs'])} tenant(s) au devis\n")
|
|
p = plan(api, devis)
|
|
if args.objets_seulement or args.vm:
|
|
inconnues = sorted(set(args.vm) - set(p["affect_majer"]) - set(p["affect_posees"]))
|
|
if inconnues:
|
|
raise SystemExit(f"REFUS : VM hors du devis ou du cluster : {inconnues}")
|
|
ecartees = len(p["affect_majer"])
|
|
p["affect_majer"] = {v: a for v, a in p["affect_majer"].items() if v in args.vm}
|
|
ecartees -= len(p["affect_majer"])
|
|
print(f" (portee restreinte : {ecartees} affectation(s) de VM laissee(s) pour plus tard)\n")
|
|
quelque_chose = afficher(p)
|
|
if not quelque_chose:
|
|
print("\n Le cluster dit deja ce que le devis dit. Rien a faire.")
|
|
return 0
|
|
if os.environ.get("CONFIRMER") != "true":
|
|
print("\n PLAN SEUL — aucune ecriture. Rejouer avec CONFIRMER=true pour appliquer.")
|
|
return 0
|
|
print()
|
|
return appliquer(api, p)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|