Set-OPS-Public/scripts/appliquer_proxmox_fw.py

254 lines
12 KiB
Python
Raw Normal View History

#!/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.
CE QU'IL NE FAIT JAMAIS : activer le pare-feu du datacenter, ni poser sa politique
d'entree a DROP. Ce reglage vaut pour TOUTES les VM du cluster, y compris celles
qui n'ont aucune regle — le basculer couperait le parc herite d'un coup. L'ecart
est signale, la decision reste humaine.
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
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 _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 ""))
def plan(api: Cluster, devis: dict) -> dict:
prefixes = tuple(_prefixe(b) for b in devis["blocs"] if _prefixe(b))
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(set(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 (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(set(membres))
groupes_poses = {}
for g in (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 (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 []
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((api(f"/nodes/{noeud}/qemu/{vmid}/firewall/options") or {}).get("enable", "")),
}
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")},
"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" or dc["politique"].upper() != "DROP":
print(f"\n /!\\ PREREQUIS NON REMPLI — pare-feu du datacenter : actif={dc['actif'] or '0'}, "
f"politique d'entree={dc['politique'] or '(defaut: ACCEPT)'}.")
print(" Sans `enable=1` ET `policy_in=DROP`, les regles ci-dessus s'ajoutent a un")
print(" tout-permis et n'interdisent RIEN.")
print(" Ce script ne le bascule PAS : le reglage vaut pour TOUTES les VM du")
print(" cluster, y compris le parc herite qui n'a aucune regle. A decider a la main.")
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")):
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}")
_fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/options", "PUT", {"enable": 1}),
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}")
for n in p["ipsets_retirer"]:
_fait(api(f"/cluster/firewall/ipset/{n}", "DELETE", {"force": 1}), 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()
r = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_proxmox_fw.py"), "--json"],
cwd=RACINE, capture_output=True, text=True,
env={**os.environ, "SETOPS_INSTANCE": str(base)})
if r.returncode != 0:
raise SystemExit("Le devis ne se genere pas :\n" + r.stderr.strip()[:400])
devis = json.loads(r.stdout)
print(f"Cluster {api.hote}{len(devis['blocs'])} tenant(s) au devis\n")
p = plan(api, devis)
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())