#!/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` : la frontiere decide QUI entre (redirection, filtre WAN) ; ce devis laisse ARRIVER ce qu'elle a laisse passer. Sans lui, un service publie serait rejete par sa propre VM (`policy_in: REJECT`). Voir `_EXTERNE` plus bas. 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] # L'Internet vu par un IPSet Proxmox : `!` = entree `nomatch` (exclusion). INTERNET = ("0.0.0.0/1", "128.0.0.0/1", "!10.0.0.0/8", "!172.16.0.0/12", "!192.168.0.0/16") sys.path.insert(0, str(RACINE / "scripts")) from resoudre_flux import ( # noqa: E402 GROUPE_EDGE, charger_flux, est_groupe_operationnel, flux_applicable, _enfants, _hotes_du_groupe, _ip_par_hote, _pairs, _resoudre_sources, ) from devis_reseau import admin_avec_tunnel, 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 # LE REGISTRE PARLE NFTABLES, PROXMOX PARLE IPTABLES (2026-09-29). `frag-needed` est le # nom nftables ; l'API Proxmox le refuse (« invalid icmp-type value ») et attend # `fragmentation-needed`. Mesure a la premiere application des flux `externe`. ICMP_PROXMOX = {"frag-needed": "fragmentation-needed"} def _cibles(fl: dict) -> list[dict]: """Ce qu'une regle vise : un port TCP/UDP, ou un TYPE ICMP. L'ICMP N'A PAS DE PORT (2026-09-28). Un flux `protocole: icmp` porte son type dans `port` (`echo-request`). `_ports` ne garde que les nombres : le type etait range parmi les « ports derives » et le flux SAUTE. Le ping de supervision, pourtant declare par `serveur_debian`, n'a donc jamais eu de regle ; la premiere VM passee en `REJECT` est devenue « morte » pour Icinga (100 % de pertes) tout en servant parfaitement. """ if str(fl.get("protocole", "tcp")).lower() == "icmp": p = fl["port"] return [{"proto": "icmp", "icmp_type": ICMP_PROXMOX.get(str(t), str(t))} for t in (p if isinstance(p, list) else [p])] return [{"proto": fl.get("protocole", "tcp"), "dport": port} for port in _ports(fl)] def _face_publiee(nom: str) -> dict | None: """La face reseau que ce locataire a publiee, ou None (et on le dit).""" try: import contexte from devis_reseau import DOSSIER_INSTANCES face = contexte.Locataire(DOSSIER_INSTANCES / nom).face_publiee() except Exception: face = None if face is None: print(f"note : {nom} n'a pas publie sa face reseau — lecture de son inventaire et de " f"ses variables (transition).", file=sys.stderr) return face 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: # LA FACE RESEAU D'ABORD (2026-10-05, docs/conception-contextes.md §6, etape 3). Le # locataire publie ses machines, ses groupes, son administration et le verdict de ses # flux conditionnels (`face-reseau.yml`) ; le devis ne lit plus son inventaire ni ses # variables. Sans face publiee (transition), l'ancienne lecture, et on le dit. face = _face_publiee(nom) inv = inventaire_de(nom) if face is None and inv is None: continue etiquette = f"{pfx}{n['index']}" if face is not None: import contexte data = contexte.inventaire_depuis_face(face) _verdicts = face.get("conditions") or {} _admin = sorted(set((face.get("administration") or {}).get("intrant") or []) | set((face.get("administration") or {}).get("tunnel") or [])) def applicable(fl, groupe, _v=_verdicts): if not fl.get("seulement_si"): return True return bool((_v.get(groupe) or {}).get(contexte.cle_condition(fl), True)) def admin_du_tenant(_nom, _a=_admin): return _a else: data = yaml.safe_load(inv.read_text(encoding="utf-8")) or {} _dossier = inv.parent.parent.parent def applicable(fl, groupe, _d=_dossier): return flux_applicable(fl, groupe, _d) admin_du_tenant = admin_avec_tunnel # 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. # L'INTERNET, SANS LES ESPACES PRIVES (2026-09-29). Source des flux `externe` : la # meme definition que la frontiere (`!SETOPS_INTERNES`). Jamais une source vide — # une regle sans source ouvre le port a tout le monde, voisins compris. Un IPSet # Proxmox refuse `0.0.0.0/0` : deux moities, et les trois blocs RFC 1918 exclus. ipsets[f"t{n['index']}-internet"] = { "role": "Internet (tout sauf RFC 1918) — flux `externe`", "membres": list(INTERNET), } if admin_du_tenant(nom): ipsets[f"t{n['index']}-admin"] = { "role": "reseaux d'administration (intrant nftables_admin_ssh)", "membres": sorted(admin_du_tenant(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 if not applicable(fl, groupe): 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]`. # CE QUE LA FRONTIERE LAISSE ENTRER DOIT POUVOIR ARRIVER (2026-09-29). Meme # partage que `devis_opnsense` : le SSH `externe` est celui de la gestion, # reserve a l'administration (garde anti-lockout, plus bas) ; tout autre # flux `externe` vient de l'Internet, et du poste s'il en est client # (`poste`, vrai par defaut — le 25 de Postfix ne l'est pas). if "externe" in pairs and not ( str(fl.get("protocole", "tcp")).lower() != "icmp" and "22" in _ports(fl)): sources = [(f"t{n['index']}-internet", "internet")] if fl.get("poste", True) and admin_du_tenant(nom): sources.append((f"t{n['index']}-admin", "administration (poste)")) for cle_src, via in sources: utilises.add(cle_src) for cible in _cibles(fl): regles.append({ "action": "ACCEPT", "sens": "IN", "source": f"+{cle_src}", "via": via, **cible, "raison": fl.get("raison", ""), }) pairs = [p for p in pairs if p != "externe"] if not pairs: continue 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(str(c.get('dport') or c.get('icmp_type')) for c in _cibles(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_du_tenant(nom): continue utilises.add(cle) for cible in _cibles(fl): regles.append({ "action": "ACCEPT", "sens": "IN", "source": f"+{cle}", "via": "administration", **cible, "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 cible in _cibles(fl): regles.append({ "action": "ACCEPT", "sens": "IN", "source": source, "via": via, **cible, "raison": fl.get("raison", ""), }) # GARDE ANTI-LOCKOUT. Le plan de gestion du tenant est resolu en # `+-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`. # `_ports` seulement pour TCP/UDP : appele sur un flux ICMP, il rangeait le # type parmi les « ports derives sautes » — un flux pourtant rendu. if (str(fl.get("protocole", "tcp")).lower() != "icmp" and "22" in _ports(fl) and admin_du_tenant(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_du_tenant(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` arrivent depuis `+t-internet`.", "# ============================================================", "", "## 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//qemu//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//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:]))