L edge n expose qu a sa zone d administration (plus de pair externe) ; le public passera par le web frontal. Le devis Proxmox recoit les flux externe depuis +t<i>-internet (RFC 1918 exclus en nomatch) et depuis l admin quand le poste en est client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
466 lines
23 KiB
Python
466 lines
23 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` : 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,
|
|
_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
|
|
|
|
|
|
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": 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 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.
|
|
# 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_avec_tunnel(nom):
|
|
ipsets[f"t{n['index']}-admin"] = {
|
|
"role": "reseaux d'administration (intrant nftables_admin_ssh)",
|
|
"membres": sorted(admin_avec_tunnel(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]`.
|
|
# 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_avec_tunnel(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_avec_tunnel(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
|
|
# `+<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`.
|
|
# `_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_avec_tunnel(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_avec_tunnel(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<index>-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/<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:]))
|