Set-OPS-Public/scripts/appliquer_proxmox_fw.py

377 lines
19 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.
flux : l interne refuse a voix haute, la bordure se tait (P53) Decision de l exploitant : block vers l Internet, reject a l interieur, parce que c est prudent. Ce n est pas le refus qui informe, c est CE QU IL FAIT AU SILENCE : sous drop partout, un timeout voulait dire aucune machine, aucune route, ou une politique. Quand la politique parle, il n en reste qu une. nftables par hote policy drop + reject with icmpx type admin-prohibited pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique) frontiere OPNsense block — INCHANGE, et c est la condition admin-prohibited ET NON tcp reset : un RST est indiscernable d un port ferme sans service. La chaine forward reste muette : elle porte le trafic qui TRAVERSE l hote, et y repondre ferait parler cette machine au nom d une destination qui n est pas elle. POURQUOI C EST PRUDENT : l obscurite etait deja nulle a l interieur (chaque machine porte un /etc/hosts qui liste ses voisines), et la bordure protege le reject — rien d indeclare ne franchit le perimetre, donc il ne repond jamais a l Internet. 982 000 entrees par jour a la frontiere, dont 82 % un balayage VNC. LA MESURE A CORRIGE LA MESURE, DEUX FOIS. Ma preuve interdisait le litteral DROP et a fait echouer un code JUSTE : la detection d une politique posee AU DATACENTER, qui est un garde-fou. Une preuve qui interdit un mot au lieu de mesurer une propriete finit par accuser ce qu elle devrait proteger. Et l absence parlait deja : EHOSTUNREACH en 3,05 s pour une machine inexistante, timeout a 6 s pour un refus de la frontiere. Mes deux erreurs de diagnostic ne venaient pas du drop mais de ma SONDE — curl et bash /dev/tcp ecrasent les deux dans un meme echec. Applique : 6 VM en REJECT, 0 creee, 0 retiree. Rien ne se ferme. Trois controles negatifs verifies. make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 16:43:22 -04:00
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
flux : l interne refuse a voix haute, la bordure se tait (P53) Decision de l exploitant : block vers l Internet, reject a l interieur, parce que c est prudent. Ce n est pas le refus qui informe, c est CE QU IL FAIT AU SILENCE : sous drop partout, un timeout voulait dire aucune machine, aucune route, ou une politique. Quand la politique parle, il n en reste qu une. nftables par hote policy drop + reject with icmpx type admin-prohibited pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique) frontiere OPNsense block — INCHANGE, et c est la condition admin-prohibited ET NON tcp reset : un RST est indiscernable d un port ferme sans service. La chaine forward reste muette : elle porte le trafic qui TRAVERSE l hote, et y repondre ferait parler cette machine au nom d une destination qui n est pas elle. POURQUOI C EST PRUDENT : l obscurite etait deja nulle a l interieur (chaque machine porte un /etc/hosts qui liste ses voisines), et la bordure protege le reject — rien d indeclare ne franchit le perimetre, donc il ne repond jamais a l Internet. 982 000 entrees par jour a la frontiere, dont 82 % un balayage VNC. LA MESURE A CORRIGE LA MESURE, DEUX FOIS. Ma preuve interdisait le litteral DROP et a fait echouer un code JUSTE : la detection d une politique posee AU DATACENTER, qui est un garde-fou. Une preuve qui interdit un mot au lieu de mesurer une propriete finit par accuser ce qu elle devrait proteger. Et l absence parlait deja : EHOSTUNREACH en 3,05 s pour une machine inexistante, timeout a 6 s pour un refus de la frontiere. Mes deux erreurs de diagnostic ne venaient pas du drop mais de ma SONDE — curl et bash /dev/tcp ecrasent les deux dans un meme echec. Applique : 6 VM en REJECT, 0 creee, 0 retiree. Rien ne se ferme. Trois controles negatifs verifies. make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 16:43:22 -04:00
# 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", "")),
flux : l interne refuse a voix haute, la bordure se tait (P53) Decision de l exploitant : block vers l Internet, reject a l interieur, parce que c est prudent. Ce n est pas le refus qui informe, c est CE QU IL FAIT AU SILENCE : sous drop partout, un timeout voulait dire aucune machine, aucune route, ou une politique. Quand la politique parle, il n en reste qu une. nftables par hote policy drop + reject with icmpx type admin-prohibited pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique) frontiere OPNsense block — INCHANGE, et c est la condition admin-prohibited ET NON tcp reset : un RST est indiscernable d un port ferme sans service. La chaine forward reste muette : elle porte le trafic qui TRAVERSE l hote, et y repondre ferait parler cette machine au nom d une destination qui n est pas elle. POURQUOI C EST PRUDENT : l obscurite etait deja nulle a l interieur (chaque machine porte un /etc/hosts qui liste ses voisines), et la bordure protege le reject — rien d indeclare ne franchit le perimetre, donc il ne repond jamais a l Internet. 982 000 entrees par jour a la frontiere, dont 82 % un balayage VNC. LA MESURE A CORRIGE LA MESURE, DEUX FOIS. Ma preuve interdisait le litteral DROP et a fait echouer un code JUSTE : la detection d une politique posee AU DATACENTER, qui est un garde-fou. Une preuve qui interdit un mot au lieu de mesurer une propriete finit par accuser ce qu elle devrait proteger. Et l absence parlait deja : EHOSTUNREACH en 3,05 s pour une machine inexistante, timeout a 6 s pour un refus de la frontiere. Mes deux erreurs de diagnostic ne venaient pas du drop mais de ma SONDE — curl et bash /dev/tcp ecrasent les deux dans un meme echec. Applique : 6 VM en REJECT, 0 creee, 0 retiree. Rien ne se ferme. Trois controles negatifs verifies. make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 16:43:22 -04:00
# 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"
flux : l interne refuse a voix haute, la bordure se tait (P53) Decision de l exploitant : block vers l Internet, reject a l interieur, parce que c est prudent. Ce n est pas le refus qui informe, c est CE QU IL FAIT AU SILENCE : sous drop partout, un timeout voulait dire aucune machine, aucune route, ou une politique. Quand la politique parle, il n en reste qu une. nftables par hote policy drop + reject with icmpx type admin-prohibited pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique) frontiere OPNsense block — INCHANGE, et c est la condition admin-prohibited ET NON tcp reset : un RST est indiscernable d un port ferme sans service. La chaine forward reste muette : elle porte le trafic qui TRAVERSE l hote, et y repondre ferait parler cette machine au nom d une destination qui n est pas elle. POURQUOI C EST PRUDENT : l obscurite etait deja nulle a l interieur (chaque machine porte un /etc/hosts qui liste ses voisines), et la bordure protege le reject — rien d indeclare ne franchit le perimetre, donc il ne repond jamais a l Internet. 982 000 entrees par jour a la frontiere, dont 82 % un balayage VNC. LA MESURE A CORRIGE LA MESURE, DEUX FOIS. Ma preuve interdisait le litteral DROP et a fait echouer un code JUSTE : la detection d une politique posee AU DATACENTER, qui est un garde-fou. Une preuve qui interdit un mot au lieu de mesurer une propriete finit par accuser ce qu elle devrait proteger. Et l absence parlait deja : EHOSTUNREACH en 3,05 s pour une machine inexistante, timeout a 6 s pour un refus de la frontiere. Mes deux erreurs de diagnostic ne venaient pas du drop mais de ma SONDE — curl et bash /dev/tcp ecrasent les deux dans un meme echec. Applique : 6 VM en REJECT, 0 creee, 0 retiree. Rien ne se ferme. Trois controles negatifs verifies. make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 16:43:22 -04:00
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}")
flux : l interne refuse a voix haute, la bordure se tait (P53) Decision de l exploitant : block vers l Internet, reject a l interieur, parce que c est prudent. Ce n est pas le refus qui informe, c est CE QU IL FAIT AU SILENCE : sous drop partout, un timeout voulait dire aucune machine, aucune route, ou une politique. Quand la politique parle, il n en reste qu une. nftables par hote policy drop + reject with icmpx type admin-prohibited pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique) frontiere OPNsense block — INCHANGE, et c est la condition admin-prohibited ET NON tcp reset : un RST est indiscernable d un port ferme sans service. La chaine forward reste muette : elle porte le trafic qui TRAVERSE l hote, et y repondre ferait parler cette machine au nom d une destination qui n est pas elle. POURQUOI C EST PRUDENT : l obscurite etait deja nulle a l interieur (chaque machine porte un /etc/hosts qui liste ses voisines), et la bordure protege le reject — rien d indeclare ne franchit le perimetre, donc il ne repond jamais a l Internet. 982 000 entrees par jour a la frontiere, dont 82 % un balayage VNC. LA MESURE A CORRIGE LA MESURE, DEUX FOIS. Ma preuve interdisait le litteral DROP et a fait echouer un code JUSTE : la detection d une politique posee AU DATACENTER, qui est un garde-fou. Une preuve qui interdit un mot au lieu de mesurer une propriete finit par accuser ce qu elle devrait proteger. Et l absence parlait deja : EHOSTUNREACH en 3,05 s pour une machine inexistante, timeout a 6 s pour un refus de la frontiere. Mes deux erreurs de diagnostic ne venaient pas du drop mais de ma SONDE — curl et bash /dev/tcp ecrasent les deux dans un meme echec. Applique : 6 VM en REJECT, 0 creee, 0 retiree. Rien ne se ferme. Trois controles negatifs verifies. make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 16:43:22 -04:00
# `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.
flux : l interne refuse a voix haute, la bordure se tait (P53) Decision de l exploitant : block vers l Internet, reject a l interieur, parce que c est prudent. Ce n est pas le refus qui informe, c est CE QU IL FAIT AU SILENCE : sous drop partout, un timeout voulait dire aucune machine, aucune route, ou une politique. Quand la politique parle, il n en reste qu une. nftables par hote policy drop + reject with icmpx type admin-prohibited pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique) frontiere OPNsense block — INCHANGE, et c est la condition admin-prohibited ET NON tcp reset : un RST est indiscernable d un port ferme sans service. La chaine forward reste muette : elle porte le trafic qui TRAVERSE l hote, et y repondre ferait parler cette machine au nom d une destination qui n est pas elle. POURQUOI C EST PRUDENT : l obscurite etait deja nulle a l interieur (chaque machine porte un /etc/hosts qui liste ses voisines), et la bordure protege le reject — rien d indeclare ne franchit le perimetre, donc il ne repond jamais a l Internet. 982 000 entrees par jour a la frontiere, dont 82 % un balayage VNC. LA MESURE A CORRIGE LA MESURE, DEUX FOIS. Ma preuve interdisait le litteral DROP et a fait echouer un code JUSTE : la detection d une politique posee AU DATACENTER, qui est un garde-fou. Une preuve qui interdit un mot au lieu de mesurer une propriete finit par accuser ce qu elle devrait proteger. Et l absence parlait deja : EHOSTUNREACH en 3,05 s pour une machine inexistante, timeout a 6 s pour un refus de la frontiere. Mes deux erreurs de diagnostic ne venaient pas du drop mais de ma SONDE — curl et bash /dev/tcp ecrasent les deux dans un meme echec. Applique : 6 VM en REJECT, 0 creee, 0 retiree. Rien ne se ferme. Trois controles negatifs verifies. make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 16:43:22 -04:00
#
# 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",
flux : l interne refuse a voix haute, la bordure se tait (P53) Decision de l exploitant : block vers l Internet, reject a l interieur, parce que c est prudent. Ce n est pas le refus qui informe, c est CE QU IL FAIT AU SILENCE : sous drop partout, un timeout voulait dire aucune machine, aucune route, ou une politique. Quand la politique parle, il n en reste qu une. nftables par hote policy drop + reject with icmpx type admin-prohibited pare-feu Proxmox policy_in = REJECT (POLITIQUE_VM, source unique) frontiere OPNsense block — INCHANGE, et c est la condition admin-prohibited ET NON tcp reset : un RST est indiscernable d un port ferme sans service. La chaine forward reste muette : elle porte le trafic qui TRAVERSE l hote, et y repondre ferait parler cette machine au nom d une destination qui n est pas elle. POURQUOI C EST PRUDENT : l obscurite etait deja nulle a l interieur (chaque machine porte un /etc/hosts qui liste ses voisines), et la bordure protege le reject — rien d indeclare ne franchit le perimetre, donc il ne repond jamais a l Internet. 982 000 entrees par jour a la frontiere, dont 82 % un balayage VNC. LA MESURE A CORRIGE LA MESURE, DEUX FOIS. Ma preuve interdisait le litteral DROP et a fait echouer un code JUSTE : la detection d une politique posee AU DATACENTER, qui est un garde-fou. Une preuve qui interdit un mot au lieu de mesurer une propriete finit par accuser ce qu elle devrait proteger. Et l absence parlait deja : EHOSTUNREACH en 3,05 s pour une machine inexistante, timeout a 6 s pour un refus de la frontiere. Mes deux erreurs de diagnostic ne venaient pas du drop mais de ma SONDE — curl et bash /dev/tcp ecrasent les deux dans un meme echec. Applique : 6 VM en REJECT, 0 creee, 0 retiree. Rien ne se ferme. Trois controles negatifs verifies. make verifier : vert. make prouver : CONFORME, 53 OK, 0 echec, 0 saute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 16:43:22 -04:00
{"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())