Set-OPS-Public/scripts/devis_proxmox_fw.py
Daniel Allaire fde604c2a5 pare-feu et pools Proxmox : les tenants du site, pas toute la federation
Les deux devis balayaient tous les dossiers freres. Le runner du site, qui
gardait un clone de patient 0, proposait de recreer ses groupes t29 ; le poste
comptait un dossier de CI comme tenant. La frontiere et le SDN filtraient deja
par underlay.tenants : ces deux-la suivent maintenant la meme liste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 09:57:06 -04:00

417 lines
20 KiB
Python

#!/usr/bin/env python3
"""Devis de pare-feu Proxmox — le filtrage EST-OUEST intra-tenant.
DERIVE du meme registre des flux que les nftables d'hote et que la frontiere. Les trois
lisent `roles/*/meta/flux.yml` par les MEMES fonctions : ils ne peuvent pas diverger.
PARTAGE DES ROLES (cf. docs/sdn-evpn.md) :
- ce devis : est-ouest INTRA-tenant, applique par l'hyperviseur ;
- `make flux` : la meme politique, rejouee sur l'hote lui-meme (defense en
profondeur — une VM compromise franchit deux barrieres) ;
- `make devis-opnsense` : tout ce qui ENTRE ou SORT d'un tenant.
Les flux `pair: externe` sont donc SAUTES ici : ils relevent de la frontiere.
NON destructif : ce script n'ecrit rien sur le cluster. Il produit un devis a relire,
puis (a venir) un JSON pour l'API Proxmox.
Usage :
python3 scripts/devis_proxmox_fw.py # devis lisible
python3 scripts/devis_proxmox_fw.py --json # meme contenu, pour l'API
python3 scripts/devis_proxmox_fw.py --verifier # garde : aucun role sans source
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(RACINE / "scripts"))
from resoudre_flux import ( # noqa: E402
GROUPE_EDGE,
charger_flux,
est_groupe_operationnel,
_enfants,
_hotes_du_groupe,
_ip_par_hote,
_pairs,
_resoudre_sources,
)
from devis_reseau import admin_de, decouvrir_du_site, inventaire_de, prefixe # noqa: E402
LONGUEUR_MAX_GROUPE = 18 # limite de Proxmox pour un nom de groupe de securite
def _role_court(role: str) -> str:
"""Forme abregee d'un role, commune aux IPSets et aux groupes."""
return role.replace("serveur_", "srv-").replace("client_", "cli-").replace("_", "-")
def nom_ipset(role: str, index: int) -> str:
"""Nom d'IPSet. MEME prefixe que les groupes — `t17-` — pour qu'un seul schema de
nommage se lise dans tout le devis.
Pas de troncature ici : Proxmox est large sur les IPSets, etroit sur les groupes.
Un nom peut donc etre entier cote IPSet et abrege cote groupe ; chacun respecte sa
contrainte, et le prefixe reste commun.
"""
return f"t{index}-{_role_court(role)}".lower()
def nom_groupe(role: str, index: int) -> str:
"""Nom de groupe de securite, sous la limite de 18 caracteres de Proxmox.
Le prefixe porte l'INDEX du tenant plutot que son etiquette : `t17-` au lieu de
`chez17-`, ce qui laisse trois caracteres de plus au role et rend la troncature
beaucoup plus rare. Les groupes sont a l'echelle du datacenter, d'ou le prefixe.
"""
return f"t{index}-{_role_court(role)}".lower()[:LONGUEUR_MAX_GROUPE]
def _verifier_unicite(noms: list[str], tenant: str) -> None:
"""Une troncature qui fait collisionner deux roles FUSIONNERAIT leurs regles.
Silencieusement, et dans le mauvais sens : une VM recevrait les autorisations d'un
role qu'elle ne porte pas. On echoue plutot que d'emettre un devis pareil.
"""
vus: dict[str, str] = {}
for role, nom in noms:
if nom in vus:
raise SystemExit(
f"erreur: {tenant} — les roles '{vus[nom]}' et '{role}' donnent le meme "
f"groupe '{nom}' apres troncature a {LONGUEUR_MAX_GROUPE} caracteres. "
f"Leurs regles fusionneraient. Renommer un role ou raccourcir le prefixe.")
vus[nom] = role
# Les ports DERIVES ne sont pas des ecoutes fixes : ils dependent de ce que l'hote porte.
# Le pare-feu de l'hyperviseur, lui, ne connait que des nombres.
#
# CONSTATE LE 2026-08-24, A L'APPLICATION : `dport: derive` est parti tel quel vers l'API,
# qui a repondu « invalid port 'derive' » -- six regles refusees. `verifier_ports` savait
# deja sauter un port non numerique ; ce generateur-ci ne le savait pas.
#
# CE QUE CA VEUT DIRE POUR POWERDNS, le seul role concerne : depuis que le resolveur du
# tenant est la seule porte, l'autoritatif n'ecoute que sur 127.0.0.1:5300. Aucune regle
# est-ouest n'a d'objet pour lui — sauter est donc la BONNE reponse, pas un contournement.
#
# Les ports sautes sont RECENSES et affiches avec le devis : un flux qu'on n'applique pas
# doit se voir, sinon c'est un trou silencieux.
PORTS_DERIVES: set[str] = set()
def _ports(fl: dict) -> list[str]:
p = fl["port"]
tous = [str(x) for x in (p if isinstance(p, list) else [p])]
fixes = [x for x in tous if x.isdigit()]
for x in tous:
if not x.isdigit():
PORTS_DERIVES.add(x)
return fixes
def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"""Le devis, en structure. Un bloc par tenant federe."""
flux_par_role = charger_flux()
blocs: list[dict] = []
for nom, pfx, n in tenants:
inv = inventaire_de(nom)
if inv is None:
continue
etiquette = f"{pfx}{n['index']}"
data = yaml.safe_load(inv.read_text(encoding="utf-8")) or {}
# Les PLANIFIES comptent : le pare-feu se prepare avant que la VM existe,
# sinon le devis changerait a chaque materialisation d'hote.
tous = set(_ip_par_hote(data))
ips = _ip_par_hote(data)
# Un IPSet par role porte dans ce tenant : les membres sont ses hotes.
# `flotte` en recoit un aussi : sans lui, six regles par tenant portaient
# quatorze adresses en dur, illisibles et impossibles a mettre a jour d'un geste.
ipsets: dict[str, dict] = {}
if ips:
ipsets[f"t{n['index']}-flotte"] = {
"role": "flotte (tous les hotes du tenant)", "membres": sorted(set(ips.values())),
}
# Le reseau d'administration a son PROPRE IPSet. L'ajouter a `flotte` ouvrirait
# aussi tous les autres flux qui s'y referent — LDAP, SQL, metriques — a un
# reseau qui n'a rien a y faire. Un ensemble, un sens.
if admin_de(nom):
ipsets[f"t{n['index']}-admin"] = {
"role": "reseaux d'administration (intrant nftables_admin_ssh)",
"membres": sorted(admin_de(nom)),
}
for groupe in sorted(_enfants(data)):
if not est_groupe_operationnel(groupe):
continue
membres = sorted({ips[h] for h in _hotes_du_groupe(data, groupe) if h in ips})
if membres:
ipsets[nom_ipset(groupe, n["index"])] = {
"role": groupe, "membres": membres,
}
groupes: list[dict] = []
sans_source: list[str] = []
utilises: set[str] = set()
for groupe in sorted(_enfants(data)):
if not est_groupe_operationnel(groupe):
continue
if not _hotes_du_groupe(data, groupe):
continue
regles: list[dict] = []
for fl in flux_par_role.get(groupe, []):
if fl.get("sens") != "ingress":
continue
pairs = _pairs(fl)
# `externe` est saute PAIR PAR PAIR, jamais le flux entier : le SSH du
# socle est declare `[flotte, externe]`, et sauter tout le flux perdait
# la moitie `flotte` — plus aucun hote joignable en SSH depuis
# l'interieur, Ansible compris. Meme piege pour le SMTP interne de
# Postfix, declare `[externe, client_smtp]`.
pairs = [p for p in pairs if p != "externe"]
if not pairs:
continue # purement externe : la frontiere s'en charge
if pairs == ["localhost"]:
continue # boucle locale : aucune regle inter-noeud
if not _resoudre_sources(data, fl.get("pair"), tous, ips):
sans_source.append(f"{groupe}:{','.join(_ports(fl))}")
continue
# UNE REGLE PAR ROLE SOURCE. Un flux dont le pair liste quatre roles
# donne quatre regles, chacune renvoyant a l'IPSet de son role : la
# regle porte alors QUI elle autorise, au lieu d'aligner des adresses.
# `flotte` et `edge` sont des mots-cles, mais ont leur IPSet eux aussi.
for pair in pairs:
if pair in ("localhost", "expositions", "derive"):
continue
cle = {"flotte": f"t{n['index']}-flotte",
"admin": f"t{n['index']}-admin",
"edge": nom_ipset(GROUPE_EDGE, n["index"])}.get(
pair, nom_ipset(pair, n["index"]))
# `admin` designe des RESEAUX, pas des hotes : `_resoudre_sources`
# rendrait une liste vide et la regle serait sautee. Son IPSet
# existe deja (garde anti-lockout SSH) — on s'y refere directement.
if pair == "admin":
if not admin_de(nom):
continue
utilises.add(cle)
for port in _ports(fl):
regles.append({
"action": "ACCEPT", "sens": "IN",
"source": f"+{cle}", "via": "administration",
"proto": fl.get("protocole", "tcp"), "dport": port,
"raison": fl.get("raison", ""),
})
continue
srcs_pair = _resoudre_sources(data, pair, tous, ips)
if not srcs_pair:
continue # ce role n'est pas porte dans ce tenant
if cle in ipsets:
source, via = f"+{cle}", pair
utilises.add(cle)
else:
source, via = ",".join(srcs_pair), pair
for port in _ports(fl):
regles.append({
"action": "ACCEPT", "sens": "IN",
"source": source, "via": via,
"proto": fl.get("protocole", "tcp"),
"dport": port,
"raison": fl.get("raison", ""),
})
# GARDE ANTI-LOCKOUT. Le plan de gestion du tenant est resolu en
# `+<t>-flotte` : les hotes du tenant, et EUX SEULS. Or Ansible se
# connecte depuis le reseau d'administration, qui n'en fait pas partie.
# Sans cette regle, la premiere VM passee en `policy_in=REJECT` se ferme
# derriere l'outil qui vient de la configurer. Meme source unique que
# les nftables et la frontiere : l'intrant `nftables_admin_ssh`.
if "22" in _ports(fl) and admin_de(nom):
utilises.add(f"t{n['index']}-admin")
regles.append({
"action": "ACCEPT", "sens": "IN",
"source": f"+t{n['index']}-admin", "via": "administration",
"proto": fl.get("protocole", "tcp"), "dport": "22",
"raison": "Garde anti-lockout : Ansible se connecte depuis le "
"reseau d'administration (intrant nftables_admin_ssh), "
"qui n'appartient pas a la flotte du tenant.",
})
if regles:
groupes.append({
"nom": nom_groupe(groupe, n["index"]), "role": groupe, "regles": regles,
})
_verifier_unicite([(g["role"], g["nom"]) for g in groupes], nom)
# Affectation : quelle VM recoit quels groupes.
affect: list[dict] = []
par_nom_groupe = {g["role"]: g["nom"] for g in groupes}
# TOUS les hotes du plan, actifs comme planifies — meme regle que les IPSets.
# Se limiter aux actifs donnait un devis different selon l'etat du tenant : un
# operateur aurait lu qu'une seule VM avait besoin de regles.
for hote in sorted(ips):
vmid = None
for g, membres in _enfants(data).items():
h = (membres.get("hosts") or {}).get(hote) or {}
if h.get("proxmox_vmid"):
vmid = h["proxmox_vmid"]
break
porte = [par_nom_groupe[g] for g in sorted(_enfants(data))
if hote in (_enfants(data)[g].get("hosts") or {}) and g in par_nom_groupe]
if porte:
affect.append({"hote": hote, "vmid": vmid, "groupes": porte})
# Roles portes mais SANS regle entrante : sous une politique REJECT ils sont
# injoignables. C'est souvent voulu (ils n'ecoutent que sur la boucle locale, ou
# seule la frontiere les atteint) — mais l'absence doit se lire, pas se deviner.
avec = {g["role"] for g in groupes}
muets: list[dict] = []
for groupe in sorted(_enfants(data)):
if not est_groupe_operationnel(groupe) or groupe in avec:
continue
if not _hotes_du_groupe(data, groupe):
continue
ing = [f for f in flux_par_role.get(groupe, []) if f.get("sens") == "ingress"]
if not ing:
motif = "aucun service ecoute"
elif all(set(_pairs(f)) <= {"localhost"} for f in ing):
motif = "n'ecoute que sur la boucle locale"
elif all(set(_pairs(f)) <= {"externe"} for f in ing):
motif = "joignable seulement par la frontiere"
else:
motif = "A VERIFIER — flux entrants declares mais aucune source resolue ici"
muets.append({"role": groupe, "motif": motif})
blocs.append({
"muets": muets,
"tenant": nom, "etiquette": etiquette,
"ipsets": {k: v for k, v in ipsets.items() if k in utilises},
"groupes": groupes,
"affectations": affect, "sans_source": sorted(set(sans_source)),
"admin": admin_de(nom),
})
return {"blocs": blocs}
def rendre(devis: dict) -> str:
out = [
"# ============================================================",
"# DEVIS PARE-FEU PROXMOX — est-ouest INTRA-tenant",
"# Genere par scripts/devis_proxmox_fw.py. NE PAS EDITER A LA MAIN.",
"# Derive du MEME registre des flux que les nftables d'hote : les deux",
"# barrieres disent la meme chose, et ne peuvent pas diverger.",
"#",
"# Ce qui ENTRE ou SORT d'un tenant n'est PAS ici : c'est la frontiere",
"# (`make devis-opnsense`). Les flux `pair: externe` sont sautes.",
"# ============================================================",
"",
"## 0. Prerequis — TROIS verrous, et le REJECT se pose PAR VM",
"#",
"# Une VM n'est filtree que si les trois sont fermes :",
"# datacenter enable=1 (defaut 0 : rien ne filtre)",
"# la VM enable=1 (defaut 0 — c'est le verrou du milieu)",
"# sa carte firewall=1 (par interface)",
"#",
"# La politique REJECT se pose SUR CHAQUE VM TENANT, jamais au datacenter :",
"#",
"# REJECT ET NON DROP : cette couche filtre l'EST-OUEST, entre machines qui se",
"# connaissent deja (leur `/etc/hosts` genere liste toutes leurs voisines). Un",
"# refus muet y coutait du diagnostic sans rien acheter — et rendait un timeout",
"# indiscernable d'une machine absente. La FRONTIERE, elle, reste en `block` :",
"# c'est ce qui rend ce reject prudent. Voir la preuve P53.",
"# pvesh set /cluster/sdn # sans toucher a policy_in",
"# pvesh set /cluster/firewall/options --enable 1",
"# pvesh set /nodes/<n>/qemu/<vmid>/firewall/options --enable 1 --policy_in REJECT",
"#",
"# `policy_in` AU DATACENTER serait la politique par defaut de TOUTE VM dont",
"# le pare-feu deviendrait actif — y compris un parc herite sans aucune regle, qui",
"# tomberait d'un bloc. Le poser par VM donne la meme isolation sans cette falaise.",
"",
]
for b in devis["blocs"]:
out += [f"## Tenant {b['tenant']} ({b['etiquette']})", ""]
out.append(f"### IPSets — {len(b['ipsets'])} (les membres d'un role, en un seul endroit)")
for nom, s in sorted(b["ipsets"].items()):
out.append(f"ipset {nom} # {s['role']}")
for ip in s["membres"]:
out.append(f" {ip}")
out += ["", f"### Groupes de securite — {len(b['groupes'])} (les flux entrants d'un role)"]
for g in b["groupes"]:
out.append(f"securitygroup {g['nom']} # {g['role']}")
# La raison est portee par le FLUX, pas par chacune de ses regles : quatre
# roles autorises sur un meme port la repeteraient quatre fois. On la sort
# une seule fois, au-dessus du paquet qu'elle explique.
derniere = None
for r in g["regles"]:
cle = (r["dport"], r["proto"], r["raison"])
if cle != derniere and r["raison"]:
out.append(f" # {r['raison']}")
derniere = cle
out.append(f" {r['sens']} {r['action']} -source {r['source']} "
f"-p {r['proto']} -dport {r['dport']}")
out += ["", f"### Affectation aux VM — {len(b['affectations'])}"]
for a in b["affectations"]:
out.append(f" vmid {a['vmid'] or '(non materialise)'} {a['hote']:<18} "
f"-> {', '.join(a['groupes'])}")
if b["muets"]:
out += ["", "### Sans regle entrante — injoignables sous la politique REJECT"]
for m in b["muets"]:
marque = " /!\\" if m["motif"].startswith("A VERIFIER") else ""
out.append(f" {m['role']:<22} {m['motif']}{marque}")
if b["sans_source"]:
out += ["", "# /!\\ Flux ingress sans source resolue dans ce tenant (aucune regle emise) :"]
out += [f"# {x}" for x in b["sans_source"]]
out.append("")
out += [
"## Defaut",
"# `policy_in REJECT` SUR CHAQUE VM TENANT : ce qui n'est pas ci-dessus n'entre pas.",
"# Un flux oublie se declare dans `roles/<role>/meta/flux.yml`, jamais a la main",
"# dans Proxmox — la regle serait perdue a la generation.",
"#",
"# Le refus est porte par la VM et non par le datacenter : l'isolation est la meme,",
"# et le parc qui n'appartient a aucun tenant garde sa politique inchangee.",
]
return "\n".join(out)
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--json", action="store_true")
ap.add_argument("--verifier", action="store_true")
args = ap.parse_args(argv)
# LES TENANTS DE CE SITE, PAS TOUTE LA FEDERATION (2026-09-28). Le balayage des
# dossiers freres ramenait un tenant retire dont le runner gardait le clone, et un
# dossier de CI sur le poste. La frontiere et le SDN filtraient deja par
# `underlay.tenants` ; ce devis equipe le meme cluster, il suit la meme liste.
devis = construire(decouvrir_du_site())
if args.verifier:
total_g = sum(len(b["groupes"]) for b in devis["blocs"])
total_r = sum(len(g["regles"]) for b in devis["blocs"] for g in b["groupes"])
if not total_g:
print("erreur: aucun groupe de securite derive — le registre ou l'inventaire "
"est vide.", file=sys.stderr)
return 2
print(f"CONFORME : pare-feu Proxmox, {len(devis['blocs'])} tenant(s), "
f"{total_g} groupe(s), {total_r} regle(s).")
return 0
print(json.dumps(devis, indent=2, ensure_ascii=False) if args.json else rendre(devis))
# UN FLUX QU'ON N'APPLIQUE PAS DOIT SE VOIR. Sauter un port derive est la bonne
# reponse, mais le taire en ferait un trou silencieux.
if PORTS_DERIVES and not args.json:
print(f"\n note : {len(PORTS_DERIVES)} port(s) DERIVE(s) non rendus ici — "
f"{', '.join(sorted(PORTS_DERIVES))}.")
print(" Un port derive depend de ce que l'hote porte ; le pare-feu de "
"l'hyperviseur ne connait que des nombres.")
print(" Cas connu : PowerDNS derriere le resolveur du tenant n'ecoute que sur "
"127.0.0.1 — aucune regle est-ouest n'a d'objet pour lui.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))