diff --git a/CHANGELOG.md b/CHANGELOG.md index edf4c22..054afe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,54 @@ # CHANGELOG — Set-OPS +## 2026-08-25 — Le pare-feu Proxmox ne s'arme que dans le SDN + +**45 preuves.** Le découpage du site en quatre zones a révélé un défaut qui dormait dans +le moteur : `firewall=1` sur l'interface d'une VM, posé sur un **pont classique**, jette +le retour des flux en épingle. + +Le mécanisme. `firewall=1` fait passer tout le trafic ponté par conntrack. Sur un VNet +SDN c'est sans conséquence : en EVPN le routage inter-VNet se fait dans le VRF, **sur le +nœud**, et le flux ne quitte jamais l'hyperviseur. Sur un pont classique routé par une +frontière externe, deux VM du **même nœud** dans deux VLAN différents ne se parlent qu'en +épingle : la trame sort par le lien physique, la frontière la route, elle revient sur le +même pont. La même table conntrack voit alors les deux moitiés de la connexion, classe le +retour `INVALID`, et `PVEFW-FORWARD` le jette. + +La mesure, et c'est elle qui a tranché : + +``` +ops(asgard) -> pki(asgard) 0/8 dns(gandalf) -> cache(gandalf) 0/8 +ops(asgard) -> forge(vishnu) 6/8 dns(gandalf) -> pki(asgard) 6/8 +ops(asgard) -> cache(gandalf) 8/8 dns(gandalf) -> forge(vishnu) 8/8 +``` + +**Toutes** les paires intra-nœud échouent, **toutes** les paires inter-nœuds passent. +Douze tentatives faisaient monter le compteur `ctstate INVALID` de **+112** sur asgard et +**+116** sur gandalf ; `firewall=0` posé, il ne bouge plus — 0 sur les deux. + +Ce qui rend le défaut coûteux, c'est son déguisement : la poignée TCP **aboutit** (SYN et +SYN-ACK créent l'état), et ce sont les paquets de **données** qui disparaissent. L'AC le +disait dans son propre journal — `TLS handshake error … i/o timeout`, elle accepte la +connexion et attend un `ClientHello` qui n'arrive jamais. On accuse le MTU, la frontière, +la zone, le port. Écartés un par un, par la mesure : règles identiques champ par champ, +alias corrects, assignation des interfaces confirmée, ARP et routes saines, IPS désactivé, +shaper vide, NAT source limité à `wan`, MTU à 1500 de bout en bout et la taille sans effet +(100 octets se perdent comme 1460). + +**La règle, désormais dans le moteur** : le pare-feu Proxmox ne s'arme que sur un VNet +SDN. L'intention déclarée ne suffit pas — un tenant déclare +`proxmox_clone_parefeu_interface: true` avec `proxmox_clone_pont: vmbr1` comme valeur *par +défaut*, chaque hôte la remplaçant par son VNet dérivé ; l'hôte qui retombe sur `vmbr1` +naîtrait armé sur un pont classique. `cloner_vm_debian.yml` croise donc l'intention avec +le pont **réellement utilisé**, et le dit quand il désarme. + +**P45** évalue l'expression du playbook sur quatre cas plutôt que d'en lire le texte, dont +un qui **doit rendre vrai** — sans lui, une expression constamment fausse passerait la +preuve sans rien garantir. + +*Le site a révélé ce défaut parce qu'il a été le premier à porter plusieurs zones. Ce +n'est pas une particularité du site : un tenant dérive ses zones du même principe.* + ## 2026-08-25 — Le SITE en quatre zones, et l'ordre inscrit dans les intégrations **44 preuves.** Le site n'est plus un `/24` plat : une zone par **nature d'autorité**, diff --git a/docs/audit/preuve-2026-08-25.md b/docs/audit/preuve-2026-08-25.md index f305796..1968466 100644 --- a/docs/audit/preuve-2026-08-25.md +++ b/docs/audit/preuve-2026-08-25.md @@ -6,8 +6,8 @@ > [`docs/audit/README.md`](README.md), et le registre trace : > [`docs/audit/affirmations.md`](affirmations.md). -- **Instance** : `instance` — inventaire `instance/inventories/production/hosts.yml` -- **Verdict** : ✅ CONFORME (44 OK · 0 echec · 0 saute) +- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/production/hosts.yml` +- **Verdict** : ✅ CONFORME (44 OK · 0 echec · 1 saute) ## Preuves @@ -28,9 +28,9 @@ | P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. | | P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. | | P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. | -| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 5 hotes, 14 groupes (inventaire dechiffre et parse). | +| P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). | | P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. | -| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 18 secret(s) exige(s), tous presents. Voute reelle : 21 cle(s), aucun manque. | +| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 18 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) | | P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 14 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. | | P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. | | P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 instance(s) federee(s), aucun index en collision. | @@ -57,6 +57,7 @@ | P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product | | P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 5 machine(s) du plan retrouvees, 56 regle(s) du site. | | P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. | +| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). | ## Couverture des affirmations ✅ du registre diff --git a/playbooks/proxmox/cloner_vm_debian.yml b/playbooks/proxmox/cloner_vm_debian.yml index 506bcf4..de4d54a 100644 --- a/playbooks/proxmox/cloner_vm_debian.yml +++ b/playbooks/proxmox/cloner_vm_debian.yml @@ -438,6 +438,21 @@ - proxmox_clone_coeurs is defined and proxmox_clone_coeurs | string | length > 0 - proxmox_clone_memoire is defined and proxmox_clone_memoire | string | length > 0 + # Un desarmement doit se VOIR. Sans ce message, l'ecart entre ce que l'ecosysteme + # declare et ce que la VM recoit resterait muet — et c'est precisement le genre de + # silence qui a coute une soiree de diagnostic le 2026-08-25. + - name: Dire que le pare-feu Proxmox reste desarme (pont classique, pas un VNet SDN) + ansible.builtin.debug: + msg: >- + `proxmox_clone_parefeu_interface` vaut vrai, mais {{ proxmox_clone_nom }} nait + sur `{{ proxmox_clone_pont }}` — un pont Linux, pas un VNet SDN. Le pare-feu + Proxmox y jetterait le retour des flux en epingle (`ctstate INVALID`) : il reste + desarme. La frontiere continue de policer ce segment. + when: + - proxmox_clone_parefeu_interface | default(false) | bool + - proxmox_clone_pont is defined + - proxmox_clone_pont is match('^vmbr[0-9]+$') + - name: Ajuster le reseau de la VM clonee community.general.proxmox_nic: api_host: "{{ proxmox_api_host_effectif }}" @@ -451,7 +466,36 @@ model: virtio bridge: "{{ proxmox_clone_pont }}" tag: "{{ proxmox_clone_vlan | int if proxmox_clone_vlan is defined and proxmox_clone_vlan | string | length > 0 else omit }}" - firewall: "{{ proxmox_clone_parefeu_interface | default(false) | bool }}" + # LE PARE-FEU PROXMOX NE S'ARME QUE SUR UN VNET SDN (2026-08-25). + # + # `firewall=1` fait passer tout le trafic ponte par conntrack. Sur un VNET SDN + # c'est sans consequence : en EVPN le routage inter-VNet se fait dans le VRF, SUR + # LE NOEUD. Le flux ne quitte jamais l'hyperviseur, conntrack en voit un chemin + # coherent, et le devis est-ouest (`devis_proxmox_fw.py`) peut le policer. + # + # Sur un PONT CLASSIQUE route par une frontiere externe, le meme reglage devient + # un piege. Deux VM du meme noeud dans deux VLAN differents ne se parlent qu'en + # EPINGLE : la trame sort par le lien physique, la frontiere la route, et elle + # revient sur LE MEME PONT. La meme table conntrack voit alors les deux moities de + # la connexion, classe le retour `INVALID`, et `PVEFW-FORWARD` le jette. + # + # MESURE DU 2026-08-25, sur le site decoupe en quatre zones : `site-ops-01` et + # `site-pki-01` (asgard), `site-dns-01` et `site-cache-01` (gandalf) ne se + # joignaient plus — 0/8 — alors que TOUTES les paires entre noeuds differents + # etaient a 8/8. Douze tentatives faisaient monter le compteur `ctstate INVALID` + # de +112 sur asgard et +116 sur gandalf ; `firewall=0` pose, il ne bouge plus. + # + # Le defaut se deguise en panne reseau : la poignee TCP aboutit (SYN/SYN-ACK + # creent l'etat), et ce sont les paquets de DONNEES qui disparaissent. On accuse + # le MTU, la frontiere, la zone. Rien de tout cela n'y est pour quelque chose. + # + # L'INTENTION DECLAREE NE SUFFIT DONC PAS : un tenant declare + # `proxmox_clone_parefeu_interface: true` avec `proxmox_clone_pont: vmbr1` comme + # valeur PAR DEFAUT, chaque hote la remplacant par son VNet derive. L'hote qui + # retombe sur `vmbr1` naitrait arme sur un pont classique. On croise donc + # l'intention avec le pont REELLEMENT utilise. Un pont Linux se nomme `vmbrN` ; + # un VNet SDN porte le nom derive de sa zone (`t29serv`). + firewall: "{{ (proxmox_clone_parefeu_interface | default(false) | bool) and (proxmox_clone_pont is not match('^vmbr[0-9]+$')) }}" # `mtu: 1` est la valeur Proxmox pour « HERITE DU PONT » (virtio uniquement). # # Sans elle, l'invite nait a 1500 quel que soit le pont. En SDN EVPN la zone est a diff --git a/roles/common_packages/defaults/main.yml b/roles/common_packages/defaults/main.yml index b72dcf7..2719ed9 100644 --- a/roles/common_packages/defaults/main.yml +++ b/roles/common_packages/defaults/main.yml @@ -50,3 +50,28 @@ common_packages_list: # Attente du verrou dpkg (secondes). Genereux : au premier demarrage, les mises a # jour automatiques de Debian le tiennent par vagues pendant plusieurs minutes. common_packages_lock_timeout: 300 + +# --- QUI DÉCIDE DE CE QUI S'INSTALLE (2026-08-25) ----------------------------- +# +# Debian arme `unattended-upgrades` et les minuteurs `apt-daily` par défaut. Set-OPS +# applique DÉJÀ les mises à jour, explicitement, dans ce rôle. **Deux mécanismes font le +# même travail sur la même flotte, et se disputent le même verrou dpkg.** +# +# Sur une machine tranquille ça ne se voit jamais. Après six redémarrages et des heures +# sans réseau — ce qu'a vécu le site le 2026-08-25 — `unattended-upgrades` rattrape tout +# son retard et tient le verrou 48 minutes. `lock_timeout` vaut 300 s : les déploiements +# échouent, trois fois de suite, sur une course que personne n'a décidée. +# +# Allonger l'attente serait un pansement : on patienterait plus longtemps devant un +# concurrent qu'on n'a pas voulu. On désarme donc, et Set-OPS reste seul maître de ce qui +# s'installe — un écosystème souverain ne laisse pas un démon décider seul quand redémarrer +# ses services. +# +# CONTREPARTIE ASSUMÉE, ET ELLE EST RÉELLE : les correctifs de sécurité ne s'appliquent +# plus qu'au passage de Set-OPS. C'est tenable si les déploiements sont réguliers, et ça +# devient une dette sinon. Mettre à `false` pour rendre la main à Debian. +common_packages_desarmer_maj_auto: true +common_packages_unites_maj_auto: + - apt-daily.timer + - apt-daily-upgrade.timer + - unattended-upgrades.service diff --git a/roles/common_packages/tasks/main.yml b/roles/common_packages/tasks/main.yml index cef43e7..281c24a 100644 --- a/roles/common_packages/tasks/main.yml +++ b/roles/common_packages/tasks/main.yml @@ -6,6 +6,21 @@ # # Attendre ici plutot que seulement avant le playbook : le verrou peut etre repris # ENTRE deux taches, ce qu'une verification ponctuelle en amont ne peut pas empecher. +# DÉSARMER AVANT TOUTE OPÉRATION APT, ET NON APRÈS. +# +# Une tâche apt placée avant celle-ci attendrait le verrou que celle-ci doit justement +# libérer — l'ordre n'est pas cosmétique. `state: stopped` en plus de `masked` : masquer +# empêche un démarrage futur, pas celui qui est déjà en cours. +- name: Désarmer les mises à jour automatiques de Debian (Set-OPS les applique lui-même) + ansible.builtin.systemd_service: + name: "{{ item }}" + state: stopped + enabled: false + masked: true + loop: "{{ common_packages_unites_maj_auto }}" + when: common_packages_desarmer_maj_auto | bool + failed_when: false # une unite absente n'est pas une faute : elle est desarmee + - name: Mettre à jour le cache APT ansible.builtin.apt: update_cache: true diff --git a/scripts/prouver.py b/scripts/prouver.py index 2bde8ca..58c3f4f 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -1068,6 +1068,81 @@ def preuve_ordre_des_integrations() -> tuple[bool, str]: return True, (f"{vus} integration(s) appliquent leur serveur avant leurs clients.") +def preuve_parefeu_proxmox_sdn_seulement() -> tuple[bool, str]: + """Le pare-feu Proxmox ne s'arme que sur un VNet SDN, jamais sur un pont classique. + + POURQUOI (2026-08-25). `firewall=1` fait passer tout le trafic ponte par conntrack. + Sur un VNet SDN c'est sans consequence : en EVPN le routage inter-VNet se fait dans le + VRF, SUR LE NOEUD, et le flux ne quitte jamais l'hyperviseur. + + Sur un pont classique route par une frontiere externe, le meme reglage devient un + piege. Deux VM du meme noeud dans deux VLAN differents ne se parlent qu'en EPINGLE : + la trame sort par le lien physique, la frontiere la route, elle revient sur LE MEME + PONT. La meme table conntrack voit les deux moities de la connexion, classe le retour + `INVALID`, et `PVEFW-FORWARD` le jette. + + Mesure : sur le site decoupe en quatre zones, les paires intra-noeud etaient a 0/8 et + toutes les paires inter-noeuds a 8/8 ; douze tentatives faisaient monter le compteur + `ctstate INVALID` de +112 sur asgard et +116 sur gandalf. `firewall=0` pose, il ne + bouge plus. Le defaut se deguise en panne reseau : la poignee TCP aboutit, et ce sont + les paquets de DONNEES qui disparaissent — on accuse le MTU, la frontiere, la zone. + + L'INTENTION DECLAREE NE SUFFIT PAS : un tenant declare + `proxmox_clone_parefeu_interface: true` avec `proxmox_clone_pont: vmbr1` comme valeur + PAR DEFAUT, chaque hote la remplacant par son VNet derive. L'hote qui retombe sur + `vmbr1` naitrait arme sur un pont classique. + + Cette preuve n'inspecte pas le texte du playbook : elle EVALUE l'expression qu'il + porte, sur quatre cas. Le cas « intention vraie sur un VNet » doit rendre VRAI — + sinon une expression constamment fausse passerait la preuve sans rien garantir. + """ + import jinja2 + + pb = RACINE / "playbooks" / "proxmox" / "cloner_vm_debian.yml" + if not pb.is_file(): + return False, f"{pb.relative_to(RACINE)} est introuvable." + lignes = [l for l in pb.read_text(encoding="utf-8").splitlines() + if re.match(r"\s*firewall:\s*", l)] + if len(lignes) != 1: + return False, (f"{pb.relative_to(RACINE)} porte {len(lignes)} ligne(s) " + f"`firewall:` — la preuve en attend exactement une, sinon elle ne " + f"sait pas laquelle gouverne la carte.") + expr = lignes[0].split("firewall:", 1)[1].strip().strip('"').strip("'") + + # Jinja2 seul ne connait ni le test `match` ni le filtre `bool` — ils viennent + # d'Ansible. On les redonne a l'identique, sinon la preuve echouerait sur l'outil + # plutot que sur ce qu'elle mesure. + env = jinja2.Environment(undefined=jinja2.StrictUndefined) + env.tests["match"] = lambda v, motif: re.match(motif, str(v)) is not None + env.filters["bool"] = lambda v: (v if isinstance(v, bool) + else str(v).strip().lower() in ("true", "yes", "on", "1")) + + cas = [ # (intention, pont, attendu, pourquoi) + (True, "t29serv", True, "un VNet SDN : le routage inter-VNet reste sur le noeud"), + (True, "vmbr3", False, "un pont classique : le retour en epingle serait jete"), + (True, "vmbr1", False, "le pont par defaut d'un tenant, si l'hote y retombe"), + (False, "t29serv", False, "l'ecosysteme ne demande pas de filtrage est-ouest"), + ] + manques: list[str] = [] + for intention, pont, attendu, pourquoi in cas: + try: + rendu = env.from_string(expr).render( + proxmox_clone_parefeu_interface=intention, proxmox_clone_pont=pont) + except jinja2.UndefinedError as e: + manques.append(f"intention={intention} pont={pont} : l'expression reclame " + f"une variable que la preuve ne fournit pas ({e})") + continue + obtenu = str(rendu).strip().lower() in ("true", "yes", "1") + if obtenu is not attendu: + manques.append(f"intention={intention} pont={pont} : rend {obtenu}, " + f"attendu {attendu} — {pourquoi}") + if manques: + return False, ("Le pare-feu Proxmox n'est pas correctement croise avec le pont :" + "\n - " + "\n - ".join(manques)) + return True, ("Le pare-feu Proxmox ne s'arme que sur un VNet SDN " + f"({len(cas)} cas evalues, dont un qui doit rendre VRAI).") + + def preuve_glossaire_enseigne() -> tuple[bool, str]: """Tout mot que le depot emploie devant l'exploitant est explique au glossaire. @@ -1409,6 +1484,8 @@ PREUVES: list[dict] = [ "func": preuve_edge_porte_ses_noms}, {"id": "P44", "titre": "Integrations : le serveur avant ses clients", "refs": [], "func": preuve_ordre_des_integrations}, + {"id": "P45", "titre": "Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs", + "refs": [], "func": preuve_parefeu_proxmox_sdn_seulement}, {"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [], "func": preuve_devis_frontiere_du_site}, {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],