From d2e2bb8990a558793ad0048dc64b589eee1fe000 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Wed, 30 Sep 2026 22:46:49 -0400 Subject: [PATCH] connectivite : une sonde a la minute remplace la matrice du pare-feu make flux ecrit, a cote de chaque .nft et depuis les memes regles, la liste de ce que chaque VM doit joindre. La sonde (nftables_baseline) la teste chaque minute avec la cause (rejete, delai, personne n'ecoute) ; client_sante la porte en mode minute ; Icinga a une fraicheur par sonde. eprouver_parefeu --sondes : tout activer d'un coup, juger par Icinga. Co-Authored-By: Claude Opus 5.5 --- Makefile | 13 +- docs/audit/preuve-2026-09-30.md | 12 +- docs/carte-set-ops.md | 2 +- docs/runbooks-construction.yml | 8 +- roles/client_sante/defaults/main.yml | 5 + roles/client_sante/tasks/main.yml | 34 ++++- .../client_sante/templates/setops-sante.sh.j2 | 24 ++- .../templates/setops-sondes-minute.service.j2 | 9 ++ .../templates/setops-sondes-minute.timer.j2 | 14 ++ roles/nftables_baseline/defaults/main.yml | 4 + roles/nftables_baseline/tasks/main.yml | 46 ++++++ .../templates/sonde-connectivite.sh.j2 | 139 ++++++++++++++++++ roles/serveur_durci/meta/supervision.yml | 11 ++ .../templates/setops-sondes.conf.j2 | 12 +- scripts/eprouver_parefeu.py | 104 +++++++++++++ scripts/prouver.py | 6 +- scripts/reconstruire_locataire.py | 7 +- scripts/resoudre_flux.py | 35 ++++- 18 files changed, 453 insertions(+), 32 deletions(-) create mode 100644 roles/client_sante/templates/setops-sondes-minute.service.j2 create mode 100644 roles/client_sante/templates/setops-sondes-minute.timer.j2 create mode 100644 roles/nftables_baseline/templates/sonde-connectivite.sh.j2 diff --git a/Makefile b/Makefile index 4d70b8a..9bef0d4 100644 --- a/Makefile +++ b/Makefile @@ -1469,19 +1469,24 @@ reconstruire-locataire: ## Rase et reconstruit un locataire, du site a la recett # matrice avant/apres, Icinga) etait rejouee par une boucle tapee a la main ; `--flotte` la # porte, avec la regle qui la rend sure : arret au premier refus. # +# PAR LES SONDES DEPUIS LE 2026-09-30 (`--sondes`) : la matrice prenait une vingtaine de +# minutes en fin de reconstruction. Chaque VM teste desormais chaque minute ses flux promis +# (sonde `connectivite`) ; on active tout d'un coup et on lit Icinga. La matrice reste pour +# le diagnostic : `python3 scripts/eprouver_parefeu.py --instance OPS-X --hote --plan`. +# # DEPUIS LE POSTE : la matrice se joue avec les acces du LOCATAIRE, l'activation passe par # le runner du SITE, seul a joindre l'API du cluster. Le poste est le seul a tenir les deux. .PHONY: parefeu-verifier-flotte parefeu-activer-flotte -parefeu-verifier-flotte: ## Verifie, sans rien activer, ce que le pare-feu Proxmox couperait sur chaque VM — INSTANCE=OPS-X +parefeu-verifier-flotte: ## La sonde `connectivite` est-elle saine sur chaque VM (prealable a l'activation) — INSTANCE=OPS-X @if [[ -z "$(INSTANCE)" ]]; then printf '%s\n' 'Refus: INSTANCE= requis.'; exit 2; fi - python3 scripts/eprouver_parefeu.py --instance "$(INSTANCE)" --flotte --plan + python3 scripts/eprouver_parefeu.py --instance "$(INSTANCE)" --flotte --sondes --plan -parefeu-activer-flotte: ## Active le pare-feu Proxmox de chaque VM, une a une, arret au premier ecart — INSTANCE=OPS-X CONFIRMER=true +parefeu-activer-flotte: ## Active le pare-feu Proxmox de toutes les VM, juge par la sonde `connectivite` — INSTANCE=OPS-X CONFIRMER=true @if [[ -z "$(INSTANCE)" || "$(CONFIRMER)" != "true" ]]; then \ printf '%s\n' 'Refus: INSTANCE= et CONFIRMER=true requis (active le pare-feu de TOUTES les VM, une a une).'; \ exit 2; \ fi - python3 scripts/eprouver_parefeu.py --instance "$(INSTANCE)" --flotte --activer + python3 scripts/eprouver_parefeu.py --instance "$(INSTANCE)" --flotte --sondes --activer # « Go ahead, make my day. » — LE bouton rouge : alias de reconstruire (Clint Eastwood). # Cree toutes les VM puis deploie tout, en une commande. Garde CONFIRMER=true. diff --git a/docs/audit/preuve-2026-09-30.md b/docs/audit/preuve-2026-09-30.md index 7a6f2ef..c4f14b8 100644 --- a/docs/audit/preuve-2026-09-30.md +++ b/docs/audit/preuve-2026-09-30.md @@ -43,17 +43,17 @@ | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 34 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 23, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. | -| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 76 scripts expliques et atteignables, 142 cibles make documentees, 69 roles avec README. | +| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 77 scripts expliques et atteignables, 146 cibles make documentees, 69 roles avec README. | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (142 cle(s) declaree(s) par l'instance). | | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 44 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). | -| P34 | Chaque document declare son lecteur | — | ✅ OK | 49 document(s) declarent leur lecteur (43 genere(s) exempte(s)). | +| P34 | Chaque document declare son lecteur | — | ✅ OK | 49 document(s) declarent leur lecteur (44 genere(s) exempte(s)). | | P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 4 application(s) exigeant une base l'ont toutes (3 entree(s) au registre). | | P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 7 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (8 groupe(s) au catalogue). | | P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. | | P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 42 role(s) serveur/client tous nommes, 42 groupe(s) cite(s) en table existent tous. | | P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 97 page(s) de wiki toutes atteignables. | | P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. | -| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 72 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | +| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 73 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | | P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (instance-ci-1646753/production, OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre | | P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 9 machine(s) du plan retrouvees, 202 regle(s) du site. | | P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. | @@ -76,7 +76,7 @@ | P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 56 champ(s) sur 7 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp | | P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:9, serveurs:3 champ(s) lus par validateur). | | P63 | cloud-init nait avec la VM et ne lui survit pas | — | ✅ OK | cloud-init est au gabarit (la premiere seconde), absent du socle (pas de va-et-vient), et retire par le durcissement — avec la garde qui verifie que le reseau s | -| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 46 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, client_s | +| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 47 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, client_s | | P65 | Depots tiers : demandes au cache, jamais en HTTPS direct | — | ✅ OK | 4 depot(s) tiers relaye(s) par le cache, aucun role ne les vise en https:// ecrit en dur. | | P66 | Clients OIDC : chaque URI vise un nom que le plan expose | — | ✅ OK | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (7 exposition(s)). | | P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 14 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. | @@ -88,14 +88,14 @@ | P73 | Le locataire designe les services de son site REEL | — | ✅ OK | CONFORME : 10 intrant(s) du locataire concordent avec ce que le site expose (1 non declare(s), donc derive(s) ou non utilise(s)). | | P74 | Gabarit dore : une seule declaration, au plan du site | — | ✅ OK | Gabarit declare une seule fois : VMID 9006 « modeleSetOPS-minimal », precedent 99998. | | P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. | -| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 182 gabarits de role : tous se rendent. | +| P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 185 gabarits de role : tous se rendent. | | P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. | | P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). | | P79 | Replis silencieux : une derivation vide ne passe pas pour un succes | — | ✅ OK | 5 ecosysteme(s) (instance-ci-1646753, OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, SITE-Chezlepro) : pattes, edges, certificats, rechargements, jumeaux d' | | P80 | Remise au client : inscrite, nommee, et son second temps a l'heure | — | ✅ OK | Aucun ecosysteme remis a un client : rien a tenir. | | P81 | La console dit sa portee, et ne sert pas un inventaire vide en silence | — | ✅ OK | Portee `poste` derivee des voutes portees, source d'inventaire `instance/inventories/principal/hosts.yml`, et 15 route(s) POST exigent toutes un pouvoir. | | P82 | DNS public : les zones publiees sont servies, signees avant d'etre exposees | — | ✅ OK | 2 zone(s) publique(s) declaree(s), toutes servies par le site ; 2 replication(s) declaree(s) des deux cotes ; transfert ouvert a la cle seule ; aucune expositio | -| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 135 etapes, 141 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. | +| P83 | Assistants : le registre des runbooks ne prend pas de retard sur le Makefile | — | ✅ OK | 17 runbooks, 139 etapes, 145 cibles documentees : chacune portee par un assistant ou exemptee avec son motif. | ## Couverture des affirmations ✅ du registre diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index a7d8c65..05f1022 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -26,7 +26,7 @@ README de rôles). Cette page comble ces deux trous. | rôles | 69 | `roles/*/` | | README de rôles | 69 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette | | documents | 45 | `docs/*.md` | -| pièces d'audit | 48 | `docs/audit/*` | +| pièces d'audit | 49 | `docs/audit/*` | | unités de wiki | 27 | `wiki/*.md` | | décisions en vigueur | 85 | lignes `\| **D-nn** \|` de `decisions-architecture.md` | | décisions renversées | 3 | lignes `\| **D-nn** —` du même document | diff --git a/docs/runbooks-construction.yml b/docs/runbooks-construction.yml index 6bf513f..17456fd 100644 --- a/docs/runbooks-construction.yml +++ b/docs/runbooks-construction.yml @@ -530,16 +530,16 @@ runbooks: portee: poste variables: [INSTANCE] pourquoi: >- - Ce que le pare-feu Proxmox couperait, VM par VM, avant de l'activer : une VM - clonee nait sans ses options de pare-feu. + La sonde `connectivite` est-elle saine sur chaque VM ? C'est le prealable : une VM + clonee nait sans ses options de pare-feu, et on n'active que sur une flotte saine. - cible: parefeu-activer-flotte nature: ecriture portee: poste variables: [INSTANCE] fixes: {CONFIRMER: "true"} pourquoi: >- - L'activer une VM a la fois, matrice avant et apres, Icinga ; arret au premier - ecart, le runner en dernier. + Tout activer d'un coup, puis lire la sonde `connectivite` que chaque VM rapporte a + la minute : seul ce qui change apres l'activation compte. Environ trois minutes. # ───────────────────────────────────────────────────────────────────────────── - id: gabarit diff --git a/roles/client_sante/defaults/main.yml b/roles/client_sante/defaults/main.yml index ed41cfc..ffdc4d9 100644 --- a/roles/client_sante/defaults/main.yml +++ b/roles/client_sante/defaults/main.yml @@ -52,6 +52,11 @@ client_sante_unites_tolerees: [] # Six passages du porteur. client_sante_ttl_sondes: 5400 +# LES SONDES A LA MINUTE (2026-09-30) — voir `setops-sante.sh.j2`, mode `minute`. Le `ttl` +# couvre trois rapports : une minute manquee ne crie pas, trois oui. +client_sante_repertoire_minute: /usr/local/lib/setops/sondes-minute +client_sante_ttl_minute: 180 + # Delai de garde de CHAQUE sonde. Une sonde est cense repondre vite ; celle qui depasse # n'a pas de verdict, et on le dit (INCONNU) plutot que de figer tout le rapport. client_sante_delai_sonde: 20 diff --git a/roles/client_sante/tasks/main.yml b/roles/client_sante/tasks/main.yml index 1a8f9e3..4b315a7 100644 --- a/roles/client_sante/tasks/main.yml +++ b/roles/client_sante/tasks/main.yml @@ -175,9 +175,19 @@ mode: "0750" when: hosts_statiques_actif | default(true) | bool +- name: Assurer le repertoire des sondes a la minute + ansible.builtin.file: + path: "{{ client_sante_repertoire_minute }}" + state: directory + owner: root + group: root + mode: "0755" + - name: Relever les sondes reellement posees ansible.builtin.find: - paths: "{{ client_sante_repertoire_sondes | default('/usr/local/lib/setops/sondes') }}" + paths: + - "{{ client_sante_repertoire_sondes | default('/usr/local/lib/setops/sondes') }}" + - "{{ client_sante_repertoire_minute }}" patterns: "*.sh" register: client_sante_posees @@ -223,11 +233,31 @@ state: started daemon_reload: true +- name: Déployer l'unité et le minuteur des sondes a la minute + ansible.builtin.template: + src: "{{ item }}.j2" + dest: "/etc/systemd/system/{{ item }}" + owner: root + group: root + mode: "0644" + loop: + - setops-sondes-minute.service + - setops-sondes-minute.timer + notify: Recharger systemd + +- name: Activer les sondes a la minute + ansible.builtin.systemd: + name: setops-sondes-minute.timer + enabled: true + state: started + daemon_reload: true + # PREMIER RAPPORT IMMEDIAT, et ce n'est pas une commodite : sans lui, le service # reste « en attente » jusqu'au premier declenchement, et un deploiement qui vient # d'installer la supervision laisserait un tableau vide qu'on prendrait pour un # tableau sain. - name: Rapporter tout de suite (ne pas laisser un tableau vide passer pour sain) - ansible.builtin.command: /usr/local/sbin/setops-sante.sh + ansible.builtin.command: "/usr/local/sbin/setops-sante.sh {{ item }}" + loop: ["", "minute"] changed_when: false failed_when: false diff --git a/roles/client_sante/templates/setops-sante.sh.j2 b/roles/client_sante/templates/setops-sante.sh.j2 index fa71910..ea792d6 100644 --- a/roles/client_sante/templates/setops-sante.sh.j2 +++ b/roles/client_sante/templates/setops-sante.sh.j2 @@ -109,9 +109,9 @@ echecs=("${restantes[@]}") # sortirait en 127 et Icinga recevrait un code hors bornes — ou rien. On rapporte donc # CRITIQUE, en le disant : le pire serait qu'un script mort produise un service muet, que # le `ttl` finirait par perimer sans que personne sache pourquoi. -sondes() { +sondes() { # $1=repertoire $2=ttl des resultats local f nom sortie code - for f in /usr/local/lib/setops/sondes/*.sh; do + for f in "$1"/*.sh; do [[ -x "$f" ]] || continue nom=$(basename "$f" .sh) # DELAI DE GARDE SUR CHAQUE SONDE — mesure du 2026-09-09. @@ -125,21 +125,31 @@ sondes() { # INCONNU (3) en le disant, plutot que de laisser le porteur mourir en silence. sortie=$(timeout {{ client_sante_delai_sonde }} "$f" 2>&1); code=$? if (( code == 124 )); then - rapporter "$nom" 3 "Sonde ${nom} sans reponse apres {{ client_sante_delai_sonde }} s — aucun verdict." "{{ client_sante_ttl_sondes }}" + rapporter "$nom" 3 "Sonde ${nom} sans reponse apres {{ client_sante_delai_sonde }} s — aucun verdict." "$2" continue fi if (( code > 3 )); then - rapporter "$nom" 2 "Sonde ${nom} en erreur (code ${code}) : ${sortie:0:160}" "{{ client_sante_ttl_sondes }}" + rapporter "$nom" 2 "Sonde ${nom} en erreur (code ${code}) : ${sortie:0:160}" "$2" else - rapporter "$nom" "$code" "${sortie:-(aucun message)}" "{{ client_sante_ttl_sondes }}" + rapporter "$nom" "$code" "${sortie:-(aucun message)}" "$2" fi done } +# LE MODE MINUTE (2026-09-30). Certaines sondes doivent battre a la minute — `connectivite`, +# qui dit qu'un flux est coupe des qu'il l'est. Elles vivent dans leur propre repertoire, +# HORS de celui-ci : sinon ce rapport-ci les rejouerait au quart d'heure avec un `ttl` de +# 90 min, et leur silence ne se verrait plus avant une heure et demie. Meme rapporteur, meme +# compte, aucun second script a tenir aligne. +if [[ "${1:-}" == "minute" ]]; then + sondes "{{ client_sante_repertoire_minute }}" "{{ client_sante_ttl_minute }}" + exit $(( ECHECS_RAPPORT > 0 ? 1 : 0 )) +fi + n=${#echecs[@]} if [[ $n -eq 0 ]]; then rapporter "sante" 0 "Aucune unite systemd en echec." "${TTL}" - sondes + sondes /usr/local/lib/setops/sondes "{{ client_sante_ttl_sondes }}" exit $(( ECHECS_RAPPORT > 0 ? 1 : 0 )) fi @@ -147,5 +157,5 @@ fi # probleme, soit du bruit qu'il faut retirer : dans les deux cas il faut agir. Un seuil # ferait vivre le bruit indefiniment, et c'est exactement ce qu'on vient de corriger. rapporter "sante" 2 "$n unite(s) systemd en echec : ${echecs[*]}" "${TTL}" -sondes +sondes /usr/local/lib/setops/sondes "{{ client_sante_ttl_sondes }}" exit $(( ECHECS_RAPPORT > 0 ? 1 : 0 )) diff --git a/roles/client_sante/templates/setops-sondes-minute.service.j2 b/roles/client_sante/templates/setops-sondes-minute.service.j2 new file mode 100644 index 0000000..823dffb --- /dev/null +++ b/roles/client_sante/templates/setops-sondes-minute.service.j2 @@ -0,0 +1,9 @@ +# GENERE par Set-OPS (role client_sante). +[Unit] +Description=Rapporter a Icinga les sondes a la minute (connectivite...) +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/setops-sante.sh minute diff --git a/roles/client_sante/templates/setops-sondes-minute.timer.j2 b/roles/client_sante/templates/setops-sondes-minute.timer.j2 new file mode 100644 index 0000000..703a4fc --- /dev/null +++ b/roles/client_sante/templates/setops-sondes-minute.timer.j2 @@ -0,0 +1,14 @@ +# GENERE par Set-OPS (role client_sante). +[Unit] +Description=Sondes a la minute (connectivite...) + +[Timer] +OnBootSec=1min +OnUnitActiveSec=60s +AccuracySec=5s +# Dispersion courte : toute la flotte ne frappe pas l'API a la meme seconde, et le +# rapport reste bien en deca du `ttl` (3 min). +RandomizedDelaySec=10 + +[Install] +WantedBy=timers.target diff --git a/roles/nftables_baseline/defaults/main.yml b/roles/nftables_baseline/defaults/main.yml index 162fd74..2a25a30 100644 --- a/roles/nftables_baseline/defaults/main.yml +++ b/roles/nftables_baseline/defaults/main.yml @@ -13,3 +13,7 @@ nftables_baseline_extra_udp_ports: [] # source (ip saddr = moindre privilège), dérivées des meta/flux.yml de l'hôte. S'il # est présent sur le contrôleur, il PRIME sur le gabarit plat ci-dessus. nftables_baseline_ruleset_genere: "{{ inventory_dir }}/../../flux-genere/{{ inventory_hostname }}.nft" + +# La liste des flux que la sonde `connectivite` teste chaque minute, generee par `make flux` +# a cote du ruleset, depuis les MEMES regles (voir templates/sonde-connectivite.sh.j2). +nftables_baseline_connectivite_genere: "{{ inventory_dir }}/../../flux-genere/{{ inventory_hostname }}.connectivite.json" diff --git a/roles/nftables_baseline/tasks/main.yml b/roles/nftables_baseline/tasks/main.yml index ddba6a1..f0bb1b4 100644 --- a/roles/nftables_baseline/tasks/main.yml +++ b/roles/nftables_baseline/tasks/main.yml @@ -80,3 +80,49 @@ state: stopped when: not (nftables_baseline_enabled | bool) failed_when: false + +# --- LA SONDE « connectivite » (2026-09-30) ------------------------------------------------ +# +# Ce que ce pare-feu promet, verifie chaque minute depuis la machine qui doit pouvoir +# joindre — et non plus seulement au moment d'une activation. Deposee dans le repertoire +# des sondes A LA MINUTE, que le porteur `client_sante` joue en mode `minute`. +- name: La liste des flux de cet hote est-elle generee ? + ansible.builtin.stat: + path: "{{ nftables_baseline_connectivite_genere }}" + delegate_to: localhost + become: false + changed_when: false + register: nftables_baseline_connectivite_stat + +- name: Assurer le repertoire des secrets et listes Set-OPS + ansible.builtin.file: + path: /etc/setops + state: directory + owner: root + group: root + mode: "0700" + +- name: Deposer la liste des flux a tester + ansible.builtin.copy: + src: "{{ nftables_baseline_connectivite_genere }}" + dest: /etc/setops/connectivite.json + owner: root + group: root + mode: "0644" + when: nftables_baseline_connectivite_stat.stat.exists + +- name: Assurer le repertoire des sondes a la minute + ansible.builtin.file: + path: /usr/local/lib/setops/sondes-minute + state: directory + owner: root + group: root + mode: "0755" + +- name: Deposer la sonde « connectivite » + ansible.builtin.template: + src: sonde-connectivite.sh.j2 + dest: /usr/local/lib/setops/sondes-minute/connectivite.sh + owner: root + group: root + mode: "0750" diff --git a/roles/nftables_baseline/templates/sonde-connectivite.sh.j2 b/roles/nftables_baseline/templates/sonde-connectivite.sh.j2 new file mode 100644 index 0000000..5a554f2 --- /dev/null +++ b/roles/nftables_baseline/templates/sonde-connectivite.sh.j2 @@ -0,0 +1,139 @@ +#!/bin/bash +# GENERE par Set-OPS (role nftables_baseline). Ne pas editer a la main. +# +# SONDE « connectivite » — chaque flux SORTANT que le registre des flux promet a cette +# machine passe-t-il, en ce moment ? Rapportee chaque minute (porteur `client_sante`, mode +# `minute`) : un flux coupe se voit dans la minute, pas au prochain deploiement. +# +# POURQUOI (2026-09-30). L'activation du pare-feu Proxmox se prouvait par une matrice rejouee +# VM par VM, avant et apres : vingt minutes en fin de reconstruction, et une preuve valable +# a l'instant de l'activation seulement. Cette sonde la remplace, et veille ensuite en +# permanence. +# +# LA LISTE n'est pas ecrite ici : `make flux` la derive des MEMES regles resolues que le +# `.nft` de chaque hote (`flux-genere/.connectivite.json`). On teste ce que le pare-feu +# applique, pas ce qu'on croit. +# +# CE QUE L'ERRNO DIT — mesure du 2026-09-30, depuis web-frontal-01 de Technolibre : +# ouvert le flux passe -> OK +# ECONNREFUSED le paquet est PASSE, personne n'ecoute sur ce port -> AVERTISSEMENT +# EHOSTUNREACH REJETE (pare-feu Proxmox : icmp host-prohibited), ou +# machine injoignable -> CRITIQUE +# delai JETE en silence (politique `drop` de nftables) -> CRITIQUE +# Un `connect()` TCP ne prouve pas une livraison au-dela d'un relais ; ici les flux sont +# directs (meme locataire, routes par la frontiere sans relais applicatif), et c'est bien +# le passage des pare-feux qu'on mesure. +# +# CE QU'ELLE OBSERVE AUSSI : les connexions entrantes ETABLIES que les entrees declarees ne +# couvrent pas (AVERTISSEMENT). Un flux reel non declare serait coupe a la prochaine regle +# posee : mieux vaut l'apprendre avant. +# +# Contrat : une ligne, code 0/1/2 (docs/supervision-conception.md). +# `CONNECTIVITE_JSON=` impose une autre liste : c'est le controle negatif. +exec python3 - "${CONNECTIVITE_JSON:-/etc/setops/connectivite.json}" <<'PY' +import concurrent.futures, errno, ipaddress, json, socket, subprocess, sys + +try: + d = json.load(open(sys.argv[1], encoding="utf-8")) +except (OSError, ValueError) as e: + print(f"Liste des flux absente ou illisible ({sys.argv[1]}) : rien n'est teste. " + "Regenerer `make flux` et redeployer nftables_baseline.") + sys.exit(1) + + +def tcp(ip, port): + s = socket.socket() + s.settimeout(2) + try: + s.connect((ip, int(port))) + return "ouvert" + except socket.timeout: + return "delai" + except OSError as e: + if e.errno == errno.ECONNREFUSED: + return "personne" + if e.errno in (errno.EHOSTUNREACH, errno.ENETUNREACH, errno.EACCES, errno.EPERM): + return "rejete" + return errno.errorcode.get(e.errno, str(e.errno)) + finally: + s.close() + + +def ping(ip): + r = subprocess.run(["ping", "-c1", "-W2", ip], capture_output=True) + return "ouvert" if r.returncode == 0 else "delai" + + +def essai(f): + return f, (ping(f["ip"]) if f["proto"] == "icmp" else tcp(f["ip"], f["port"])) + + +sorties = d.get("sorties") or [] +with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex: + resultats = list(ex.map(essai, sorties)) + +coupes = [(f, r) for f, r in resultats if r not in ("ouvert", "personne")] +personne = [(f, r) for f, r in resultats if r == "personne"] +ouverts = len(resultats) - len(coupes) - len(personne) + +# Entrees etablies hors des regles declarees. +def lignes(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout.splitlines() + except (OSError, subprocess.TimeoutExpired): + return [] + +ecoute = {l.split()[3].rsplit(":", 1)[1] for l in lignes(["ss", "-Hltn"]) if len(l.split()) >= 4} +reseaux = {} +for e in d.get("entrees") or []: + if e.get("proto") == "tcp": + reseaux.setdefault(str(e["port"]), []).extend(e.get("sources") or []) +reseaux.setdefault("22", []).extend(d.get("admin_ssh") or []) +hors = set() +for l in lignes(["ss", "-Hnt", "state", "established"]): + c = l.split() + if len(c) < 4: + continue + lport = c[2].rsplit(":", 1)[1] + pair = c[3].rsplit(":", 1)[0].strip("[]").replace("::ffff:", "") + if lport not in ecoute: + continue + try: + adr = ipaddress.ip_address(pair) + except ValueError: + continue + if adr.is_loopback: + continue + permis = False + for r in reseaux.get(lport, []): + try: + if adr in ipaddress.ip_network(str(r), strict=False): + permis = True + break + except ValueError: + pass + if not permis: + hors.add(f"{pair}->{lport}") + +def nomme(xs): + return ", ".join(f"{f['vers']}:{f['port'] if f['proto'] != 'icmp' else 'ping'} ({r})" for f, r in xs[:5]) \ + + (f" et {len(xs) - 5} autre(s)" if len(xs) > 5 else "") + +perf = f"| ouverts={ouverts};;;0 coupes={len(coupes)};;;0 sans_service={len(personne)};;;0 hors_regles={len(hors)};;;0" +if coupes: + print(f"COUPE : {len(coupes)}/{len(resultats)} flux sortant(s) ne passent pas — {nomme(coupes)}. {perf}") + sys.exit(2) +avert = [] +if personne: + avert.append(f"{len(personne)} flux passent mais personne n'ecoute — {nomme(personne)}") +if hors: + avert.append(f"{len(hors)} connexion(s) entrante(s) hors des regles declarees — " + + ", ".join(sorted(hors)[:5])) +if avert: + print(f"{ouverts}/{len(resultats)} flux sortants ouverts ; " + " ; ".join(avert) + f". {perf}") + sys.exit(1) +if not resultats: + print(f"Aucun flux sortant declare pour {d.get('hote', 'cet hote')} ; aucune entree hors des regles. {perf}") + sys.exit(0) +print(f"Connectivite : {ouverts}/{len(resultats)} flux sortants ouverts ; aucune entree hors des regles. {perf}") +PY diff --git a/roles/serveur_durci/meta/supervision.yml b/roles/serveur_durci/meta/supervision.yml index b66ba5a..2bdc09e 100644 --- a/roles/serveur_durci/meta/supervision.yml +++ b/roles/serveur_durci/meta/supervision.yml @@ -27,3 +27,14 @@ sondes: La piste d'audit s'ecrit-elle vraiment ? Des regles armees dans le noyau ne prouvent pas qu'un demon collecte : le 2026-08-30, quinze machines ont eu les deux apparences de la sante sans qu'une seule ligne soit ecrite. + - nom: connectivite + ttl: 180 + # Attendue a la minute, et non au quart d'heure : c'est tout son interet. Voir le mode + # `minute` du porteur (`client_sante`). + fraicheur: 180 + raison: >- + Chaque flux que le registre promet a cette machine passe-t-il, maintenant ? Un pare-feu + (Proxmox ou nftables) qui coupe un flux declare ne casse rien de visible tout de suite : + la sauvegarde echoue cette nuit, le certificat ne se renouvelle pas demain. Teste chaque + minute depuis la machine qui doit joindre, avec la cause (rejete, delai, personne + n'ecoute) — et les connexions entrantes que les regles declarees ne couvrent pas. diff --git a/roles/serveur_icinga/templates/setops-sondes.conf.j2 b/roles/serveur_icinga/templates/setops-sondes.conf.j2 index 923f826..d6a119a 100644 --- a/roles/serveur_icinga/templates/setops-sondes.conf.j2 +++ b/roles/serveur_icinga/templates/setops-sondes.conf.j2 @@ -41,6 +41,11 @@ (`defini_par`, par defaut le role declarant) — la valeur meme que ce role voit. `test_sondes_conditionnelles.py` garde l'accord entre le `when:` et la declaration. #} +{# + UNE FRAICHEUR PAR SONDE, SI ELLE LA DECLARE (2026-09-30). `connectivite` bat a la minute : + attendue au quart d'heure et demi comme les autres, son silence ne se verrait qu'au bout + de 90 min. `fraicheur:` dans `meta/supervision.yml`, sinon la valeur commune. +#} {% for role, sondes in (serveur_icinga_sondes | default({})) | dictsort %} {% for hote in ((groups[role] | default([])) | intersect(groups['client_sante'] | default([]))) | sort %} {% for sonde in sondes %} @@ -50,12 +55,13 @@ {% set valeur = hostvars[hote][condition] if hostvars[hote][condition] is defined else defauts.get(condition) %} {% endif %} {% if not condition or valeur %} +{% set fraicheur = sonde.fraicheur | default(serveur_icinga_fraicheur_sondes) %} object Service "{{ sonde.nom }}" { import "setops-rapport-attendu" host_name = "{{ hote }}" - check_interval = {{ serveur_icinga_fraicheur_sondes }}s - retry_interval = {{ serveur_icinga_fraicheur_sondes }}s - vars.dummy_text = "AUCUN RAPPORT depuis {{ (serveur_icinga_fraicheur_sondes / 60) | int }} min : cette sonde ne rapporte plus, ou n a jamais rapporte." + check_interval = {{ fraicheur }}s + retry_interval = {{ fraicheur }}s + vars.dummy_text = "AUCUN RAPPORT depuis {{ (fraicheur / 60) | int }} min : cette sonde ne rapporte plus, ou n a jamais rapporte." vars.setops_source = "{{ hote }}" vars.setops_role = "{{ role }}" vars.setops_raison = "{{ (sonde.raison | default('')) | replace('"', "'") | replace('\n', ' ') | trim }}" diff --git a/scripts/eprouver_parefeu.py b/scripts/eprouver_parefeu.py index 687a84b..f3c50fd 100644 --- a/scripts/eprouver_parefeu.py +++ b/scripts/eprouver_parefeu.py @@ -224,6 +224,106 @@ def critiques_icinga(instance: str) -> list[str]: return ["(Icinga illisible)"] +def vmids(instance: str) -> dict[str, int]: + """{hote: vmid} du devis de ce locataire — la meme source que l'activation.""" + env = dict(os.environ, SETOPS_INSTANCE=str(DOSSIER_INSTANCES / instance)) + r = subprocess.run([sys.executable, "scripts/devis_proxmox_fw.py", "--json"], + capture_output=True, text=True, cwd=RACINE, env=env) + if r.returncode != 0: + raise SystemExit("Le devis ne se genere pas :\n" + r.stderr[:400]) + table = {} + for b in (x for x in json.loads(r.stdout)["blocs"] if x.get("tenant") == instance): + for a in b["affectations"]: + table[a["hote"]] = int(a["vmid"]) + return table + + +def etat_sonde(instance: str, nom: str) -> dict[str, tuple[int, str, float]]: + """{hote: (etat, texte, fin du dernier rapport)} du service `nom`, lu dans Icinga.""" + cmd = ("P=$(sed -n '/ApiUser \"root\"/,/}/p' /etc/icinga2/conf.d/api-users.conf | " + "grep -oP 'password = \"\\K[^\"]+'); " + "curl -sk -u \"root:$P\" -H 'Accept: application/json' " + f"'https://localhost:5665/v1/objects/services?filter=service.name==%22{nom}%22" + "&attrs=host_name&attrs=state&attrs=last_check_result'") + sortie = next(iter(_ansible(instance, "serveur_icinga", cmd).values()), "{}") + table = {} + try: + for x in json.loads(sortie[sortie.index("{"):])["results"]: + a = x["attrs"] + r = a.get("last_check_result") or {} + table[a["host_name"]] = (int(a.get("state", 3)), str(r.get("output", "")), + float(r.get("execution_end") or 0)) + except (ValueError, KeyError): + pass + return table + + +def activer_par_sondes(instance: str, args) -> int: + """La flotte entiere d'un coup, jugee par la sonde `connectivite` (2026-09-30). + + La matrice VM par VM prenait une vingtaine de minutes en fin de reconstruction, pour une + preuve valable a l'instant de l'activation seulement. Ici : chaque VM teste deja, chaque + minute, les flux que le registre lui promet, et le dit a Icinga. On verifie que tout est + sain AVANT, on active tout, on attend les rapports suivants, et seul ce qui CHANGE compte. + Perdu, en connaissance de cause : l'isolement d'une faute a une seule VM — la sonde, elle, + nomme le couple exact qui ne passe plus. + """ + table = vmids(instance) + avant = etat_sonde(instance, "connectivite") + manquent = sorted(set(table) - set(avant)) + if manquent: + print(f" REFUS : aucune sonde `connectivite` dans Icinga pour {', '.join(manquent)} — " + "redeployer `serveur_durci` et `client_sante`, puis `serveur_icinga`.") + return 2 + deja = {h: v for h, v in avant.items() if h in table and v[0] == 2} + if deja: + print(" REFUS : des flux sont DEJA coupes, avant toute activation — les comprendre d'abord :") + for h, (_, texte, _) in sorted(deja.items()): + print(f" {h} : {texte[:180]}") + return 2 + print(f" avant : {len(table)} VM, `connectivite` saine partout " + f"({sum(1 for h in table if avant[h][0] == 1)} avertissement(s))") + crit_avant = set(critiques_icinga(instance)) + if args.plan: + print(" PLAN SEUL — rien n'a ete active.") + return 0 + t0 = time.time() + print(f" activation de {len(table)} VM d'un coup a {time.strftime('%H:%M:%S')}") + distante = (f"cd {args.racine_runner}/Set-OPS-public && export PATH={args.racine_runner}/venv/bin:$PATH " + f"&& SETOPS_INSTANCE={args.racine_runner}/{instance} make proxmox-fw-appliquer " + f"CONFIRMER=true ARGS='{' '.join(f'--vm {v}' for v in sorted(table.values()))}'") + r = subprocess.run(["ssh", "-o", "BatchMode=yes", "-J", args.rebond, args.runner, + f"sudo -u setops bash -lc {json.dumps(distante)}"], capture_output=True, text=True) + if not (("reconcilie" in r.stdout or "Rien a faire" in r.stdout) and "ECHEC" not in r.stdout): + print(" ECHEC de l'activation :") + for ligne in (r.stdout + r.stderr).splitlines()[-8:]: + print(" " + ligne) + return 3 + # On attend que CHAQUE VM ait rapporte APRES l'activation (rapport a la minute, `ttl` 3 min). + limite = t0 + 300 + while True: + time.sleep(20) + apres = etat_sonde(instance, "connectivite") + frais = [h for h in table if h in apres and apres[h][2] > t0 + 5] + print(f" {time.strftime('%H:%M:%S')} : {len(frais)}/{len(table)} VM ont rapporte depuis l'activation") + if len(frais) == len(table) or time.time() > limite: + break + absents = sorted(set(table) - set(frais)) + coupes = {h: apres[h] for h in frais if apres[h][0] == 2} + for h, (_, texte, _) in sorted(coupes.items()): + print(f" COUPE {h} : {texte[:200]}") + crit = sorted(set(critiques_icinga(instance)) - crit_avant) + print(f" Icinga : {len(crit)} critique(s) apparu(s)" + (f" — {', '.join(crit)}" if crit else "")) + if absents: + print(f" SANS NOUVELLE de {', '.join(absents)} apres 5 min — muette, ou coupee d'Icinga.") + if coupes or crit or absents: + print(" RETOUR ARRIERE d'une VM : `enable=0` sur /nodes//qemu//firewall/options " + f"(vmid : {', '.join(f'{h}={table[h]}' for h in sorted(set(coupes) | set(absents)))})") + return 4 + print(f" {len(table)}/{len(table)} VM : pare-feu Proxmox actif, `connectivite` saine partout.") + return 0 + + def ordre_flotte(instance: str) -> list[str]: """Toutes les VM du locataire, son RUNNER EN DERNIER. @@ -248,6 +348,8 @@ def main() -> int: cible.add_argument("--hote", help="la VM, ex. idm-01") cible.add_argument("--flotte", action="store_true", help="toutes les VM, une a une, le runner en dernier ; arret au premier refus") + ap.add_argument("--sondes", action="store_true", + help="avec --flotte : tout activer d'un coup, juge par la sonde `connectivite` d'Icinga") mode = ap.add_mutually_exclusive_group(required=True) mode.add_argument("--plan", action="store_true", help="verifier, sans rien activer") mode.add_argument("--activer", action="store_true", help="verifier, activer, reverifier") @@ -257,6 +359,8 @@ def main() -> int: args = ap.parse_args() if args.hote: return eprouver(args.instance, args.hote, args) + if args.sondes: + return activer_par_sondes(args.instance, args) # UNE A UNE, ET ON S'ARRETE AU PREMIER REFUS (2026-09-30). C'etait une boucle tapee a # la main apres chaque reconstruction ; la regle qui la rend sure — ne pas continuer # apres un ecart — doit vivre avec elle, pas dans la memoire de celui qui la tape. diff --git a/scripts/prouver.py b/scripts/prouver.py index 8febee0..93c0987 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -2550,7 +2550,9 @@ def preuve_sondes_declarees_et_deposees() -> tuple[bool, str]: fautes.append(f"{role}/{nom} : `ttl` manquant — sans lui, le SILENCE " f"n'alerte pas, et c'est la moitie de l'interet") # Le role doit deposer le script au chemin du contrat. - if f"/usr/local/lib/setops/sondes/{nom}.sh" not in corps: + # `sondes-minute/` : les sondes que le porteur joue chaque minute (2026-09-30). + if (f"/usr/local/lib/setops/sondes/{nom}.sh" not in corps + and f"/usr/local/lib/setops/sondes-minute/{nom}.sh" not in corps): fautes.append(f"{role}/{nom} : declaree mais jamais deposee dans " f"`tasks/main.yml` — le service existerait sans jamais avoir " f"de resultat") @@ -2562,7 +2564,7 @@ def preuve_sondes_declarees_et_deposees() -> tuple[bool, str]: for taches in sorted(roles.glob("*/tasks/main.yml")): role = taches.parent.parent.name corps = taches.read_text(encoding="utf-8") - deposees = set(re.findall(r"/usr/local/lib/setops/sondes/([A-Za-z0-9_.-]+)\.sh", corps)) + deposees = set(re.findall(r"/usr/local/lib/setops/sondes(?:-minute)?/([A-Za-z0-9_.-]+)\.sh", corps)) meta = roles / role / "meta" / "supervision.yml" declarees = set() if meta.is_file(): diff --git a/scripts/reconstruire_locataire.py b/scripts/reconstruire_locataire.py index 3fb1606..8f04dde 100644 --- a/scripts/reconstruire_locataire.py +++ b/scripts/reconstruire_locataire.py @@ -251,9 +251,12 @@ def main() -> int: if time.time() > limite: return arret(nom, 4, "delai de surveillance depasse — le montage continue sur le runner") elif nom == "parefeu": - rc = lancer([sys.executable, "-u", "scripts/eprouver_parefeu.py", "--instance", T, "--flotte", "--activer", + # PAR LES SONDES, PAS PAR LA MATRICE (2026-09-30) : tout d'un coup, juge par la sonde + # `connectivite` que chaque VM rapporte a la minute. Trois minutes au lieu de vingt. + rc = lancer([sys.executable, "-u", "scripts/eprouver_parefeu.py", "--instance", T, "--flotte", + "--activer", "--sondes", "--runner", args.runner, "--rebond", args.rebond, "--racine-runner", args.racine_runner], - env=env, filtre=("=== ", "avant :", "apres :", "Icinga", "ARRET", "VM : pare-feu", "REFUS")) + env=env, filtre=(" ",)) else: # bilan rc = lancer(["ansible-playbook", "-i", str(inv), "playbooks/backup/restauration.yml"], env=env, filtre=("jeu ", "fatal:")) diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index 595f2e1..a4506db 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -20,6 +20,7 @@ from __future__ import annotations import glob import os +import json import sys from pathlib import Path @@ -707,6 +708,13 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) - sortie_dir = _inventaire().parent.parent.parent / "flux-genere" sortie_dir.mkdir(parents=True, exist_ok=True) ecrits: list[Path] = [] + # CE QUE CHAQUE VM DOIT POUVOIR JOINDRE, ET DE QUI ELLE ACCEPTE (2026-09-30). La sonde + # `connectivite` (role nftables_baseline) les teste chaque minute. On les ecrit ICI, a + # cote des `.nft` et depuis les MEMES regles resolues : une seconde derivation finirait + # par diverger de la premiere, et la sonde testerait ce que le pare-feu n'applique pas. + hote_par_ip = {ip: h for h, ip in ip_par_hote.items()} + sorties: dict[str, dict[tuple, dict]] = {} + entrees: dict[str, list[dict]] = {} for hote in sorted(actifs_noms): groupes = [g for g, membres in _enfants(data).items() if hote in (membres.get("hosts") or {}) and est_groupe_operationnel(g)] @@ -780,11 +788,25 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) - and "22" in [str(x) for x in ports]) if _public and not local and not _ssh and not site: saddr = "" + proto = str(fl.get("protocole", "tcp")).lower() for port in ports: if local and not srcs: continue # lo deja accepte regles.append(f" {saddr}{_selecteur_nft(fl['protocole'], port)} accept" f" # {g}: {fl.get('raison','')}") + entrees.setdefault(hote, []).append( + {"proto": proto, "port": str(port), "role": g, + "sources": list(srcs) if saddr else ["0.0.0.0/0"]}) + # Testable depuis la source : TCP, et l'ICMP « echo » (ping). L'UDP ne se + # « connecte » pas ; les autres types ICMP ne se provoquent pas. + if proto == "tcp" or (proto == "icmp" and str(port) == "echo-request"): + for src in srcs: + de = hote_par_ip.get(str(src)) + if de and de != hote: + sorties.setdefault(de, {}).setdefault( + (hote, proto, str(port)), + {"vers": hote, "ip": ip_par_hote[hote], "proto": proto, + "port": str(port), "role": g}) contenu = _rendre_nft(hote, regles) chemin = sortie_dir / f"{hote}.nft" chemin.write_text(contenu, encoding="utf-8") @@ -803,7 +825,18 @@ def generer_nftables(flux_par_role: dict[str, list[dict]], site: bool = False) - # suivent : un dossier GENERE doit etre le reflet exact de sa source, donc la # generation doit aussi SUPPRIMER. La garde est posee en meme temps que le remede — # **P53** voyait deja l'orphelin, puisqu'un ruleset perime n'a pas de refus audible. - for orphelin in sorted(sortie_dir.glob("*.nft")): + for hote in sorted(actifs_noms): + chemin = sortie_dir / f"{hote}.connectivite.json" + chemin.write_text(json.dumps({ + "hote": hote, + "genere_par": "scripts/resoudre_flux.py (make flux) — ne pas editer", + "sorties": sorted(sorties.get(hote, {}).values(), + key=lambda x: (x["vers"], x["proto"], x["port"])), + "entrees": entrees.get(hote, []), + "admin_ssh": list(admin_ssh), + }, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") + ecrits.append(chemin) + for orphelin in sorted(list(sortie_dir.glob("*.nft")) + list(sortie_dir.glob("*.connectivite.json"))): if orphelin not in ecrits: orphelin.unlink() print(f"note : {orphelin.name} retire — cet hote n'est plus au plan.",