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:
parent
49a7e12850
commit
d2e2bb8990
18 changed files with 453 additions and 32 deletions
13
Makefile
13
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 <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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
14
roles/client_sante/templates/setops-sondes-minute.timer.j2
Normal file
14
roles/client_sante/templates/setops-sondes-minute.timer.j2
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
139
roles/nftables_baseline/templates/sonde-connectivite.sh.j2
Normal file
139
roles/nftables_baseline/templates/sonde-connectivite.sh.j2
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 }}"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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:"))
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue