proxmox-fw : le DROP se pose par VM, jamais au datacenter (D-64)

Le devis enseignait « politique d'entree DROP au datacenter ». Or policy_in y
est la politique par defaut de TOUTE VM dont le pare-feu s'active — 37 machines
heritees sans regles, sur ce cluster. `policy_in` existe aussi par VM : le devis
et l'applicateur le posent la. Meme isolation, sans falaise, et l'applicateur
n'a plus a refuser une partie de son devis.

Trois verrous, pas un : datacenter enable=1, `enable` de la VM (defaut 0), et
`firewall=1` sur la carte. C'est le verrou du milieu que j'avais manque en
annoncant que huit VM en production tomberaient.

`enable=1` au datacenter bascule et verifie : pve-firewall running, 12 chaines
cadres, AUCUNE chaine par VM, 0 regle visant roxanne, 15 VM toujours en marche,
hyperviseurs et frontiere joignables, sortie tenant 2/2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-06 20:03:41 -04:00
parent b3447d5a96
commit d61c187dcf
4 changed files with 94 additions and 21 deletions

View file

@ -2,6 +2,47 @@
## 2026-08-06 — le chemin nord-sud devient dérivable
### Le DROP se pose par VM, pas au datacenter (D-64)
Le devis enseignait un geste dangereux : « pare-feu activé, politique d'entrée DROP » **au
niveau du datacenter**. Or `policy_in` y est la politique par défaut de **toute** VM dont le
pare-feu s'active. Sur ce cluster, cela vise 37 machines héritées qui n'ont aucune règle.
`policy_in` existe aussi **par VM**. Le devis et l'applicateur le posent désormais là :
```
datacenter enable=1, policy_in laissé au défaut ACCEPT
VM tenant enable=1 + policy_in=DROP + carte firewall=1
VM héritée rien — politique inchangée, pare-feu éteint
```
Même isolation est-ouest, sans le moment où tout bascule. Et l'applicateur n'a plus besoin de
refuser une partie de son devis : la partie dangereuse a disparu.
**Trois verrous, pas un.** Une VM n'est filtrée que si le datacenter est actif, que **son
propre** `enable` vaut 1 — défaut **0**, c'est le verrou du milieu — et que sa carte porte
`firewall=1`. C'est ce verrou du milieu que j'avais manqué en annonçant que huit VM en
production tomberaient.
### `enable=1` au datacenter : mesuré, pas supposé
Basculé avec vérification immédiate. Après :
```
pve-firewall enabled/running
chaînes iptables 12, toutes des chaînes-cadres PVEFW-*
chaînes par VM aucune
règles visant roxanne 0
15 VM en marche 15
hyperviseurs, frontière joignables
sortie tenant 2/2, 13 ms
```
`enable=1` pose **le cadre** et rien d'autre. Ce que le schéma laissait prévoir est maintenant
constaté sur la machine — la distinction compte, et c'est la seule raison d'avoir tenté le
geste plutôt que de l'écrire.
### Un applicateur pour le pare-feu est-ouest — et un refus assumé
`scripts/appliquer_proxmox_fw.py` réconcilie les trois couches du devis : 26 IPSets,

View file

@ -43,6 +43,7 @@ sont les seules vérifiables.
| **D-11** | La **sortie générale est déclarée** dans le registre des flux | `block out` est un vrai default-deny ; un besoin oublié ne se manifeste pas par un refus clair | `frontiere-opnsense.md` §8 | P09 |
| **D-29** | L'**overlay EVPN plafonne à 1450** ; le transport doit donc dépasser 1500 | choix d'exploitation ; l'encapsulation VXLAN coûte 50 octets | `sdn-evpn.md` §5 | P23 (MTU du transport) |
| **D-30** | L'**ICMP « fragmentation nécessaire » est déclaré**, dans les deux sens | à 1450, tout ce qui traverse la frontière dépend de la découverte de MTU de chemin ; une bordure en default-deny la casse en silence | `roles/serveur_debian/meta/flux.yml` | P09 |
| **D-64** | La politique `DROP` du pare-feu Proxmox se pose **sur chaque VM tenant**, jamais au datacenter | au datacenter, `policy_in` est la politique par défaut de **toute** VM dont le pare-feu s'active — un parc hérité sans règles tomberait d'un bloc. Par VM : même isolation, sans falaise, et le reste du cluster garde sa politique | `devis_proxmox_fw.py` §0 | P25 |
| **D-62** | La sortie d'un VRF tenant emprunte **un seul saut** (`nexthop-vrf default` vers la frontière), jamais la table principale | `import vrf default` ferait hériter au tenant le défaut de l'hyperviseur — sortie **contournant la frontière** — et lui donnerait au passage le transport VXLAN, la gestion et les VLAN hérités. Un saut emprunté n'est pas une table héritée | `devis_sdn.py` §4 | P30 |
| **D-63** | Tout supernet **routé** doit être **traduit** : le NAT sortant est dérivé, pas laissé en « automatique » | le mode automatique ne couvre que les réseaux *directement attachés* ; un supernet joint par route statique en sort sans avertissement. Panne muette : le filtre passe, un état s'ouvre, rien ne revient | `devis_opnsense.py` §2bis | P24 |
| **D-61** | L'**interface d'une règle se dérive de l'attachement réel** de sa source, jamais du sens du flux | une règle OPNsense est toujours `in` sur l'interface d'arrivée ; posée ailleurs elle ne peut **jamais** correspondre, et le devis affirme alors un accès qui n'existe pas | `frontiere-opnsense.md` §7 | P24 |

View file

@ -10,10 +10,13 @@ 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.
LE DROP 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.
@ -93,11 +96,15 @@ def plan(api: Cluster, devis: dict) -> dict:
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((api(f"/nodes/{noeud}/qemu/{vmid}/firewall/options") or {}).get("enable", "")),
"actif": str(o.get("enable", "")),
# Le DROP 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 {}
@ -115,7 +122,8 @@ def plan(api: Cluster, devis: dict) -> dict:
"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")},
or affect_posees[v]["actif"] != "1"
or affect_posees[v]["politique"] != "DROP")},
"affect_posees": affect_posees,
"vm_absentes": sorted(vm_absentes),
"datacenter": {"actif": str(opts.get("enable", "")), "politique": str(opts.get("policy_in", ""))},
@ -144,13 +152,19 @@ def afficher(p: dict) -> bool:
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.")
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"])
@ -209,8 +223,10 @@ def appliquer(api: Cluster, p: dict) -> int:
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}")
# `policy_in=DROP` 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.
_fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/options", "PUT",
{"enable": 1, "policy_in": "DROP"}), 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"]:

View file

@ -249,9 +249,21 @@ def rendre(devis: dict) -> str:
"# (`make devis-opnsense`). Les flux `pair: externe` sont sautes.",
"# ============================================================",
"",
"## 0. Prerequis, au niveau du datacenter",
"# Pare-feu active, politique d'entree DROP. Sans cela, ces regles",
"# s'ajoutent a un tout-permis et n'interdisent rien.",
"## 0. Prerequis — TROIS verrous, et le DROP 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 DROP se pose SUR CHAQUE VM TENANT, jamais au datacenter :",
"# 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 DROP",
"#",
"# `policy_in DROP` 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"]:
@ -290,9 +302,12 @@ def rendre(devis: dict) -> str:
out.append("")
out += [
"## Defaut",
"# Politique d'entree DROP au datacenter : 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.",
"# `policy_in DROP` 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 DROP 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)