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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-30 22:46:49 -04:00
parent 49a7e12850
commit d2e2bb8990
18 changed files with 453 additions and 32 deletions

View file

@ -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 <vm> --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=<OPS-X> 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=<OPS-X> 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.

View file

@ -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

View file

@ -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 |

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 ))

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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"

View file

@ -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/<hote>.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=<fichier>` 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

View file

@ -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.

View file

@ -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 }}"

View file

@ -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/<noeud>/qemu/<vmid>/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.

View file

@ -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():

View file

@ -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:"))

View file

@ -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.",