diff --git a/CHANGELOG.md b/CHANGELOG.md index c199d67..e49cf0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/docs/decisions-architecture.md b/docs/decisions-architecture.md index f61abe8..2ad3ae8 100644 --- a/docs/decisions-architecture.md +++ b/docs/decisions-architecture.md @@ -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 | diff --git a/scripts/appliquer_proxmox_fw.py b/scripts/appliquer_proxmox_fw.py index 47820e3..465ea81 100644 --- a/scripts/appliquer_proxmox_fw.py +++ b/scripts/appliquer_proxmox_fw.py @@ -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"]: diff --git a/scripts/devis_proxmox_fw.py b/scripts/devis_proxmox_fw.py index 91a2ae1..d2c5c5a 100644 --- a/scripts/devis_proxmox_fw.py +++ b/scripts/devis_proxmox_fw.py @@ -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//qemu//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//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//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)