diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c35716..ea70358 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,80 @@ # CHANGELOG — Set-OPS +## 2026-09-10 (14) — Le site surveille enfin sa fabric + +La supervision du site voyait ses sept VM et rien d'autre. Mesure de depart, depuis +`site-mon-01` : les trois hyperviseurs, les neuf pattes de la frontiere et **sa propre +passerelle par defaut** rendaient tous 100 % de perte au ping. + + 16 hotes UP / 16 dont 9 pattes de frontiere en controle ACTIF + 11 cibles Prometheus dont 3 hyperviseurs, job `fabric` separe + +### Le partage : ce qui peut porter un agent, et ce qui ne le peut pas + +**Les hyperviseurs** portent `node_exporter` — D-48 l'autorise. Chacun expose 6 600 a +7 900 lignes de metriques, dont **1 005 unites systemd avec leur etat** : soit exactement +ce que la sonde `sante` mesurait, plus la charge, le disque et l'horloge. + +**Ils n'entrent PAS dans le socle**, et c'est le point delicat. Un hyperviseur Proxmox +n'est pas une VM de la flotte : lui appliquer `serveur_durci` reecrirait son pare-feu, son +SSH et ses sysctl — sur la machine qui tient tout le reste. Ils vivent dans leur propre +groupe, hors de `GROUPE_SOCLE` et de `hotes_actifs`. + +**La frontiere** n'accueille aucun agent : controle ACTIF, une entree par PATTE. La +frontiere est un seul boitier, mais chaque zone depend de SON interface — une interface +eteinte coupe une zone pendant que les autres vont bien, et on en a deja vu (les routes +creees `disabled` le 2026-09-02). Un ping vers une seule adresse dirait « la frontiere est +debout » et manquerait ce cas. + +### On TIRE, on ne pousse pas — et c'est la route gelee qui le decide + +J'avais propose du passif, et il avait ete valide. **La mesure a dit non** : + + asgard -> 10.0.36.11 via 192.168.11.254 (le routeur du site) + route par defaut via 192.168.11.254 GELEE (D-57) + +Un hyperviseur envoie vers un routeur qui ne connait pas les reseaux du site : le porteur +de sante y expirait en 20 s. Le remede evident — router 10.0.0.0/8 par la frontiere — +touche la route par defaut d'une machine EN SERVICE, ce que D-57 interdit. On tire donc, +dans le sens que la frontiere route deja. + +### Le vrai defaut : une liste qui n'a pas suivi + +Le mecanisme de routage EXISTAIT sur `vmbr0`, avec un commentaire du 2026-08-26 tenant +exactement le raisonnement qu'on venait de refaire. Sa liste s'arretait a `10.0.34.0/24` : + + le site declare 6 zones (31 -> 36) + les hyperviseurs 4 routes (31 -> 34) + +Les zones `sauvegarde` (35) et `supervision` (36) sont nees, **les routes n'ont pas suivi**. +Le symptome ne ressemblait pas a une route manquante : il ressemblait a un pare-feu, puis a +un probleme de reseau chez l'exploitant. + +**`make routes-fabric-etat`** compare desormais TROIS choses : les zones declarees, les +routes declarees dans `/etc/network/interfaces`, et les routes vivantes dans le noyau. Le +cas le plus traitre est le troisieme — *vivante mais non declaree* : tout fonctionne, la +supervision est verte, et la panne attend la prochaine maintenance. Une garde qui ne +comparerait que le vivant ne le verrait jamais. Eprouvee dans les deux sens. + +### Deux defauts trouves en construisant + +**`bifrost-2` est un nom reserve, pas un boitier.** L'underlay le disait — « pour que les +noms soient reserves » — mais en PROSE, illisible par le moteur. Le surveiller aurait donne +deux CRITICAL permanents pour un equipement absent. Il porte desormais `etat: reserve` ; le +jour ou le second boitier arrive, retirer la cle le fait entrer dans la supervision. + +**Un service passif n'existe que pour un hote qui peut POUSSER.** Les hyperviseurs sont +dans `client_metrique` — c'est par ce groupe qu'on leur deploie node_exporter — et la +derivation en tirait `asgard!metriques`. Icinga refusait la configuration ENTIERE. Meme en +definissant l'hote, le service serait reste UNKNOWN pour toujours. *L'appartenance a un +groupe sert deux choses qui ne coincident pas toujours : a qui l'on deploie, et de qui l'on +attend un rapport.* La seconde se lit sur `client_sante`, et nulle part ailleurs. + +### Les commutateurs restent dehors + +Choix de l'exploitant, coherent avec D-48 : ils sont hors flotte, et rien ne les rend +interrogeables sans leur ouvrir un acces qu'on ne veut pas leur ouvrir. + ## 2026-09-10 (13) — D-88 : le noeud du gabarit, point unique de la REPRODUCTION Question de l'exploitant : *« le modele vit sur vishnu, les clones sont sur asgard — qu' diff --git a/Makefile b/Makefile index 48f4b05..38e79a5 100644 --- a/Makefile +++ b/Makefile @@ -1071,6 +1071,14 @@ site-appliquer: ## Applique un role aux machines du site — GROUPE= notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). | | P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site | | P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. | @@ -43,7 +43,7 @@ | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 34 VM placee(s), aucun nom ni VMID en collision. | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 32 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 21, 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, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. | -| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 58 scripts expliques et atteignables, 115 cibles make documentees, 67 roles avec README. | +| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 59 scripts expliques et atteignables, 116 cibles make documentees, 67 roles avec README. | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (132 cle(s) declaree(s) par l'instance). | | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (36 groupes). | | P34 | Chaque document declare son lecteur | — | ✅ OK | 44 document(s) declarent leur lecteur (36 genere(s) exempte(s)). | @@ -53,16 +53,16 @@ | P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 40 role(s) serveur/client tous nommes, 41 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, 27 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 : 54 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | +| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 55 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 (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product | -| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 151 regle(s) du site. | +| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 153 regle(s) du site. | | P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. | | P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). | | P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos | | P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). | | P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 89 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. | -| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (121 lignes). | -| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 213 regles `pass`), tous non consignes et tous motives. | +| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (123 lignes). | +| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 215 regles `pass`), tous non consignes et tous motives. | | P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 | | P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. | | P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b | diff --git a/docs/registre-flux.md b/docs/registre-flux.md index 3bf5696..1c6cf3f 100644 --- a/docs/registre-flux.md +++ b/docs/registre-flux.md @@ -51,6 +51,7 @@ | `serveur_icinga` | ingress | 5665 | tcp | serveur_debian | tls-requis | Rapport passif de sante de chaque noeud (unites systemd en echec). | | `serveur_icinga` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base relationnelle du moteur Icinga (verify-full). | | `serveur_icinga` | egress | echo-request | icmp | serveur_debian | n-a | La supervision verifie que ses hotes repondent (hostalive) : sans ce flux, elle les tient tous pour morts et supprime leurs notifications. | +| `serveur_icinga` | egress | echo-request | icmp | fabric | n-a | La supervision verifie que la frontiere sert encore chaque zone. Aucun agent ne peut vivre sur un pare-feu : le controle ACTIF est le seul chemin, et il n'existait pas. | | `serveur_icingaweb2` | ingress | 8080 | tcp | edge | clair | Interface web servie via l'edge (TLS terminé à l'edge ; SSO possible via oauth2-proxy). | | `serveur_icingaweb2` | egress | 636 | tcp | serveur_openldap | tls-requis | Authentification des utilisateurs sur l'annuaire (LDAPS). | | `serveur_icingaweb2` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Lecture d'IcingaDB (base relationnelle, verify-full). | @@ -96,6 +97,7 @@ | `serveur_powerdns` | egress | 53 | tcp | externe | clair | Repli TCP de la résolution sortante du serveur autoritatif (réponses dépassant la taille UDP). | | `serveur_prometheus` | ingress | 9090 | tcp | localhost | clair | Console Prometheus consommée en local par Grafana co-localisé (pas d'exposition inter-nœud). | | `serveur_prometheus` | egress | 9100 | tcp | client_metrique | tls | Scrape des node_exporter (HTTPS via cert step-ca) sur chaque nœud instrumenté. | +| `serveur_prometheus` | egress | 9100 | tcp | fabric | clair | Scrape des hyperviseurs : la machine qui PORTE les VM etait invisible de la supervision qui les surveille. Elle ne peut pas pousser (route par defaut gelee, D-57), donc on la tire. | | `serveur_redis` | ingress | 6379 | tcp | localhost | clair | Cache/verrous consommés uniquement par l'application co-localisée (ex. Nextcloud). Aucune exposition inter-nœud. | | `serveur_resolveur` | ingress | 53 | udp | flotte | clair | Toute la flotte du tenant résout ici — et nulle part ailleurs. | | `serveur_resolveur` | ingress | 53 | tcp | flotte | clair | Réponses longues et bascule TCP, obligatoires en DNS. | @@ -112,8 +114,8 @@ ## Synthèse chiffrement -- **clair** : 37 flux -- **n-a** : 5 flux +- **clair** : 38 flux +- **n-a** : 6 flux - **ssh** : 8 flux - **starttls** : 6 flux - **tls** : 10 flux diff --git a/roles/client_sante/defaults/main.yml b/roles/client_sante/defaults/main.yml index 0a8a69a..cb3d125 100644 --- a/roles/client_sante/defaults/main.yml +++ b/roles/client_sante/defaults/main.yml @@ -4,6 +4,21 @@ # echoue chaque heure apprend a ignorer le rouge. client_sante_icinga_hote: "{{ (groups['serveur_icinga'] | default([]) | first) | default('') }}" +# L'ADRESSE DU TEMOIN, PAR SON NOM — SAUF QUAND LE NOM N'EXISTE PAS (2026-09-10). +# +# Le porteur visait `.`, ce qui suppose le plancher `/etc/hosts` pose par +# le socle. Les HYPERVISEURS ne l'ont pas, et ne doivent pas l'avoir : Proxmox se sert de +# `/etc/hosts` pour l'identite de noeud du cluster, et la reecrire est un risque qu'on ne +# prend pas pour une commodite de supervision. +# +# Mesure : `curl: (6) Could not resolve host: site-mon-01.genese.internal` sur les trois +# hyperviseurs, alors que le porteur etait correctement installe et tournait. +# +# On DERIVE donc l'URL, ce qui laisse une machine sans resolveur la surcharger par une +# ADRESSE. Une seule chose change, et elle se declare la ou le nom ne marche pas. +client_sante_icinga_url: >- + https://{{ client_sante_icinga_hote }}.{{ domaine_interne }}:5665 + # LE MEME COMPTE D'API QUE LE RAPPORT DE SAUVEGARDE, et c'est un choix. # # Un second compte serait plus pur — un secret par usage. Il exigerait une CLEF DE VOUTE diff --git a/roles/client_sante/templates/setops-sante.sh.j2 b/roles/client_sante/templates/setops-sante.sh.j2 index 083a8f9..fa71910 100644 --- a/roles/client_sante/templates/setops-sante.sh.j2 +++ b/roles/client_sante/templates/setops-sante.sh.j2 @@ -24,7 +24,7 @@ # ce qui n'a alerte personne. set -uo pipefail -API="https://{{ client_sante_icinga_hote }}.{{ domaine_interne }}:5665" +API="{{ client_sante_icinga_url }}" TTL={{ client_sante_ttl_icinga }} MOI="{{ inventory_hostname }}" MOTDEPASSE="$(cat /etc/setops/icinga-api.pass)" diff --git a/roles/serveur_icinga/defaults/main.yml b/roles/serveur_icinga/defaults/main.yml index 647ed25..3bc47f3 100644 --- a/roles/serveur_icinga/defaults/main.yml +++ b/roles/serveur_icinga/defaults/main.yml @@ -154,3 +154,7 @@ serveur_icinga_sonde_age_crit: 300 # DEGRADE, JAMAIS DEVINE : pas de cache d'amorcage declare, pas de reecriture. serveur_icinga_depot_schema: >- {{ 'http' if (artefacts_amorcage | default('') | string | length > 0) else 'https' }} + +# LA FABRIC SURVEILLEE, DERIVEE DE L'UNDERLAY PAR L'INVENTAIRE DU SITE. +# Forme : [{ nom, adresse, role }]. Vide chez un tenant — il n'a pas de fabric a lui. +serveur_icinga_fabric: [] diff --git a/roles/serveur_icinga/meta/flux.yml b/roles/serveur_icinga/meta/flux.yml index e8dd274..50e2817 100644 --- a/roles/serveur_icinga/meta/flux.yml +++ b/roles/serveur_icinga/meta/flux.yml @@ -96,3 +96,27 @@ flux: pair: serveur_postgresql chiffrement: tls-requis raison: "Base relationnelle du moteur Icinga (verify-full)." + + # LA PASSERELLE FAIT PARTIE DE CE QUI PEUT TOMBER (2026-09-10). + # + # La supervision du site voyait ses sept VM et rien d'autre. Mesure : depuis + # `site-mon-01`, AUCUNE patte de la frontiere ne repondait — pas meme `10.0.36.1`, sa + # PROPRE passerelle par defaut. OPNsense ne repond pas a l'ICMP sur ses interfaces + # internes tant qu'une regle ne l'autorise pas. + # + # UNE PATTE PAR ZONE, ET C'EST LE POINT. La frontiere est un seul boitier, mais chaque + # zone du site depend de SON interface a elle. Une interface eteinte — on en a deja vu, + # les routes creees `disabled` le 2026-09-02 — coupe une zone pendant que les autres + # vont bien. Un ping vers une seule patte dirait « la frontiere est debout » et + # manquerait exactement ce cas-la. + # + # `fabric` couvre les hyperviseurs ET la frontiere : c'est le materiel de l'hebergeur, + # celui qu'aucun agent ne peut habiter. + - sens: egress + port: echo-request + protocole: icmp + pair: fabric + chiffrement: n-a + raison: >- + La supervision verifie que la frontiere sert encore chaque zone. Aucun agent ne peut + vivre sur un pare-feu : le controle ACTIF est le seul chemin, et il n'existait pas. diff --git a/roles/serveur_icinga/templates/setops-hotes.conf.j2 b/roles/serveur_icinga/templates/setops-hotes.conf.j2 index 87203c1..bbd9f27 100644 --- a/roles/serveur_icinga/templates/setops-hotes.conf.j2 +++ b/roles/serveur_icinga/templates/setops-hotes.conf.j2 @@ -19,3 +19,26 @@ object Host "{{ noeud }}" { vars.role = "{{ 'depot de sauvegarde' if noeud == serveur_icinga_hote_sauvegarde else 'noeud de l ecosysteme' }}" } {% endfor %} + +/* + * LA FABRIC — CE QUI PORTE TOUT, ET QU'AUCUN AGENT NE PEUT HABITER (2026-09-10). + * + * Un pare-feu n'accueille pas de porteur de sante : le controle ACTIF est le seul chemin + * possible, et il n'existait pas. La supervision du site voyait ses sept VM et rien + * d'autre — pas meme la passerelle par defaut de la machine qui la fait tourner. + * + * UNE ENTREE PAR PATTE, ET C'EST LE POINT. La frontiere est un seul boitier, mais chaque + * zone depend de SON interface. Une interface eteinte — on en a deja vu, les routes + * creees `disabled` le 2026-09-02 — coupe une zone pendant que les autres vont bien. + * Surveiller une seule adresse dirait « la frontiere est debout » et manquerait ce cas. + * + * L'ADRESSE, JAMAIS UN NOM : ces equipements ne sont dans aucun annuaire, et le plancher + * `/etc/hosts` ne les porte pas. Un nom fabrique ici ne resoudrait nulle part. + */ +{% for f in serveur_icinga_fabric | default([]) %} +object Host "{{ f.nom }}" { + check_command = "hostalive" + address = "{{ f.adresse }}" + vars.role = "{{ f.role }}" +} +{% endfor %} diff --git a/roles/serveur_icinga/templates/setops-sondes.conf.j2 b/roles/serveur_icinga/templates/setops-sondes.conf.j2 index 5599ec0..f98191a 100644 --- a/roles/serveur_icinga/templates/setops-sondes.conf.j2 +++ b/roles/serveur_icinga/templates/setops-sondes.conf.j2 @@ -15,8 +15,25 @@ * Les HOTES sont definis dans `setops-hotes.conf` : ici, rien que des services. */ +{# + * UN SERVICE PASSIF N'EXISTE QUE POUR UN HOTE QUI PEUT POUSSER (2026-09-10). + * + * Les hyperviseurs ont rejoint `client_metrique` — c'est par ce groupe qu'on leur + * DEPLOIE node_exporter. La derivation en tirait donc `asgard!metriques`, et Icinga + * refusait la configuration ENTIERE : + * + * Attribute 'host_name': Object 'asgard' of type 'Host' does not exist. + * + * Meme en definissant l'hote, le service serait reste UNKNOWN pour toujours : un + * hyperviseur ne porte pas `client_sante`, parce que sa route par defaut est gelee et + * qu'il ne peut rien pousser (D-57). On le TIRE avec Prometheus. + * + * L'appartenance a un groupe sert donc DEUX choses qui ne coincident pas toujours : a + * qui l'on deploie, et de qui l'on attend un rapport. La seconde se lit sur le porteur + * lui-meme — `client_sante` — et nulle part ailleurs. +#} {% for role, sondes in (serveur_icinga_sondes | default({})) | dictsort %} -{% for hote in (groups[role] | default([])) | sort %} +{% for hote in ((groups[role] | default([])) | intersect(groups['client_sante'] | default([]))) | sort %} {% for sonde in sondes %} object Service "{{ sonde.nom }}" { host_name = "{{ hote }}" diff --git a/roles/serveur_prometheus/meta/flux.yml b/roles/serveur_prometheus/meta/flux.yml index f796a1d..ed6a7f0 100644 --- a/roles/serveur_prometheus/meta/flux.yml +++ b/roles/serveur_prometheus/meta/flux.yml @@ -13,3 +13,23 @@ flux: pair: localhost chiffrement: clair raison: "Console Prometheus consommée en local par Grafana co-localisé (pas d'exposition inter-nœud)." + + # LA FABRIC SE SURVEILLE EN LA TIRANT (2026-09-10). + # + # Les hyperviseurs portent `node_exporter` — D-48 l'autorise : « hors flotte » ne vaut + # que pour les commutateurs et la frontiere. Mais ils ne peuvent pas POUSSER : leur route + # par defaut vise le routeur du site, qui ne connait pas les reseaux du site, et cette + # route est GELEE (D-57). Mesure : le porteur de sante y expirait en 20 s. + # + # Prometheus tire donc, dans le sens que la frontiere route deja pour `serveur_ops_site`. + # 7887 lignes de metriques sur `asgard`, dont 1005 unites systemd avec leur etat — soit + # davantage que ce qu'un ping ou un porteur de sante auraient dit. + - sens: egress + port: 9100 + protocole: tcp + pair: fabric + chiffrement: clair + raison: >- + Scrape des hyperviseurs : la machine qui PORTE les VM etait invisible de la + supervision qui les surveille. Elle ne peut pas pousser (route par defaut gelee, + D-57), donc on la tire. diff --git a/scripts/routes_fabric_etat.py b/scripts/routes_fabric_etat.py new file mode 100644 index 0000000..a3e25a5 --- /dev/null +++ b/scripts/routes_fabric_etat.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Les hyperviseurs routent-ils toutes les zones que le SITE declare ? Ne corrige rien. + +POURQUOI CE CONTROLE EXISTE (2026-09-10). + +Un hyperviseur atteint les zones du site par des routes SPECIFIQUES posees sur `vmbr0`, +et non par sa route par defaut — celle-ci est GELEE (D-57) et vise le routeur du site. +Le commentaire qui les accompagne, ecrit le 2026-08-26, dit pourquoi : + + Aller et retour empruntent la MEME patte — pf cree son etat sur une seule + interface, et une reponse revenant par une autre serait jetee. + +CE QUE CA A COUTE. Le site a gagne deux zones — `site-sauvegarde` (35) et +`site-supervision` (36) — et la liste des routes ne les a pas suivies. Elle s'arretait a +34. Consequence mesuree : la supervision du site ne pouvait PAS collecter les +hyperviseurs, parce que la reponse partait vers un routeur qui ne connait pas 10.0.36.0/24. +Le symptome ne ressemblait pas a une route manquante — il ressemblait a un pare-feu, puis +a un probleme de reseau chez l'exploitant. + +Une liste qui doit suivre une autre liste finit toujours par prendre du retard. La seule +question est de savoir si quelqu'un s'en apercevra avant la panne. + +CE QU'ON COMPARE, ET DANS LES DEUX SENS : + + 1. les zones DECLAREES par `underlay.reseaux` (la verite du site) ; + 2. les routes DECLAREES dans `/etc/network/interfaces` (ce qui survit au redemarrage) ; + 3. les routes VIVANTES dans la table du noyau (ce qui vaut maintenant). + +Une route vivante mais non declaree disparait au prochain redemarrage — c'est le cas le +plus traitre, parce que tout fonctionne jusqu'a la maintenance suivante. + +Ce n'est pas une preuve du harnais : elle exigerait le cluster, que `make prouver` ne +suppose pas joignable. C'est un controle a la demande, comme `make gabarit-etat`. + +Usage : + python3 scripts/routes_fabric_etat.py # code de sortie 0 si conforme +""" +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import underlay as underlay_mod # noqa: E402 + +# La patte de la frontiere sur le plan d'administration : le prochain saut de ces routes. +# DERIVEE, jamais ecrite : c'est l'hote `frontiere` du reseau `grappe-controle`. +RESEAU_PROCHAIN_SAUT = "grappe-controle" + + +def _lire(hote: str, commande: str) -> str: + r = subprocess.run( + ["ssh", "-o", "ConnectTimeout=8", "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=no", f"ansible@{hote}", commande], + capture_output=True, text=True, timeout=60) + return r.stdout if r.returncode == 0 else "" + + +def main() -> int: + u = underlay_mod.charger() + if not u: + print("Aucun underlay monte : rien a comparer.") + return 0 + + hotes = underlay_mod.hotes(u) + + # 1. Les zones du site, telles que la carte les declare. + zones = sorted({str(r["sous_reseau"]) for r in underlay_mod.reseaux(u) + if str(r.get("nom", "")).startswith("site-") and r.get("sous_reseau")}) + if not zones: + print("Le site ne declare aucune zone : rien a router.") + return 0 + + saut = next((str(h["ip"]) for h in hotes + if h.get("role") == "frontiere" + and str(h.get("reseau")) == RESEAU_PROCHAIN_SAUT and h.get("ip")), "") + if not saut: + print(f"Aucune patte `frontiere` sur `{RESEAU_PROCHAIN_SAUT}` : " + f"le prochain saut ne se derive pas.") + return 1 + + # Les hyperviseurs, par leur adresse d'administration — la seule que le poste route. + noeuds = {} + for h in hotes: + if h.get("role") != "hyperviseur" or not h.get("ip"): + continue + if str(h.get("reseau")) != RESEAU_PROCHAIN_SAUT: + continue + noeuds.setdefault(str(h["nom"]), str(h["ip"])) + if not noeuds: + print("Aucun hyperviseur sur le plan d'administration : rien a interroger.") + return 0 + + print(f"Zones declarees par le site : {len(zones)} — prochain saut {saut}") + ecarts: list[str] = [] + for nom, ip in sorted(noeuds.items()): + conf = _lire(ip, "cat /etc/network/interfaces") + table = _lire(ip, "ip route show") + if not conf and not table: + ecarts.append(f"{nom} ({ip}) : injoignable — le controle ne peut rien dire") + print(f" {nom:<9} INJOIGNABLE") + continue + declarees = set(re.findall(r"ip route replace (\d+\.\d+\.\d+\.\d+/\d+) via " + re.escape(saut), conf)) + vivantes = set(re.findall(r"^(\d+\.\d+\.\d+\.\d+/\d+) via " + re.escape(saut), table, re.M)) + manque_decl = [z for z in zones if z not in declarees] + manque_vive = [z for z in zones if z not in vivantes] + # Le cas le plus traitre : vivante mais non declaree — elle part au redemarrage. + ephemeres = sorted(vivantes - declarees) + etat = "=" if not (manque_decl or manque_vive or ephemeres) else "≠" + print(f" {nom:<9} declarees {len(declarees)}/{len(zones)} " + f"vivantes {len(vivantes)}/{len(zones)} {etat}") + for z in manque_decl: + ecarts.append(f"{nom} : {z} n'est pas declaree dans `/etc/network/interfaces` " + f"— elle ne survivra pas au redemarrage") + for z in manque_vive: + ecarts.append(f"{nom} : {z} absente de la table du noyau — les reponses vers " + f"cette zone partent par la route par defaut et se perdent") + for z in ephemeres: + ecarts.append(f"{nom} : {z} est VIVANTE mais non declaree — elle disparaitra " + f"au prochain redemarrage, et tout marchera jusque-la") + + if ecarts: + print("\nECART — la fabric ne route pas ce que le site declare :") + for e in ecarts: + print(f" - {e}") + print(f"\n Remede, sur le noeud concerne :") + print(f" ip route replace via {saut} dev vmbr0 # tout de suite") + print(f" puis la meme ligne en `post-up` dans /etc/network/interfaces # pour apres") + print(" L'ordre importe peu ; faire les DEUX importe.") + return 1 + + print("\nConforme — chaque hyperviseur route toutes les zones du site, " + "declarees ET vivantes.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index cb075b2..de112a0 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -311,6 +311,44 @@ def inventaire() -> dict: if erreurs: raise SystemExit("plan du site refuse :\n - " + "\n - ".join(erreurs)) + # LES ADRESSES DE LA FABRIC, pour que Prometheus puisse la TIRER (2026-09-10). + # + # Le job principal de Prometheus vaut `client_metrique ∩ hotes_actifs`, et les + # hyperviseurs sont volontairement HORS de `hotes_actifs` : ce groupe sert aux + # operations de flotte (`deployer-tout`), et un hyperviseur n'est pas une VM de la + # flotte. L'intersection les excluait donc, alors meme qu'ils portent node_exporter. + # + # `serveur_prometheus_cibles_supplementaires` est le point d'extension prevu par le + # role. On y met la fabric sous son PROPRE job : elle n'est pas la flotte, et un + # tableau qui les melange ferait croire a un parc homogene. + _fabric_metriques = sorted( + f"{h['ip']}:9100" for h in U.hotes(u) + if str(h.get("role")) == "hyperviseur" and str(h.get("ip", "")).startswith("192.168.11.")) + + # CE QUE LA SUPERVISION IRA VOIR : chaque patte de la frontiere, nommee par la zone + # qu'elle sert. Les commutateurs restent dehors — D-48 les tient hors flotte, et rien + # ne les rend interrogeables sans leur ouvrir un acces qu'on ne veut pas leur ouvrir. + _fabric_surveillee = [] + for _h in U.hotes(u): + if str(_h.get("role")) != "frontiere": + continue + # UN NOM RESERVE N'EST PAS UN EQUIPEMENT. `bifrost-2` est declare pour que le lien + # de transit soit documente et que le nom soit pris — le boitier n'existe pas. + # Le surveiller donnerait deux CRITICAL permanents, et une alarme toujours rouge + # est une alarme qu'on cesse de lire. + if str(_h.get("etat") or "actif") != "actif": + continue + _ip = str(_h.get("ip") or "") + _res = str(_h.get("reseau") or "") + if not _ip: + continue + _fabric_surveillee.append({ + "nom": f"frontiere-{_ip.replace('.', '-')}", + "adresse": _ip, + "role": f"frontiere — patte {_res or _ip}", + }) + _fabric_surveillee.sort(key=lambda x: x["adresse"]) + hostvars: dict[str, dict] = {} groupes: dict[str, list[str]] = {} @@ -440,6 +478,17 @@ def inventaire() -> dict: # Le plan du site, pour les roles qui lisent des registres. "setops_plan_dir": str(plan_dir() or ""), "hosts_statiques_expositions": expositions, + # LA FABRIC SURVEILLEE PAR CONTROLE ACTIF (2026-09-10). + # + # Un pare-feu n'accueille aucun agent : Icinga doit aller VOIR. Une entree par + # PATTE de la frontiere, parce que chaque zone depend de son interface a elle — + # une interface eteinte coupe une zone pendant que les autres vont bien. + **({"serveur_icinga_fabric": _fabric_surveillee} + if (_fabric_surveillee and "serveur_icinga" in _groupes_de(nom)) else {}), + # La fabric, en job separe — voir `_fabric_metriques` plus haut. + **({"serveur_prometheus_cibles_supplementaires": + [{"job": "fabric", "cibles": _fabric_metriques}]} + if (_fabric_metriques and "serveur_prometheus" in _groupes_de(nom)) else {}), **communes, # En dernier : ce qu'une machine declare d'elle-meme prime sur ce que le # plan declare pour toutes. @@ -461,6 +510,93 @@ def inventaire() -> dict: if hote in hostvars and groupe: groupes.setdefault(str(groupe), []).append(hote) + # --- LES HYPERVISEURS ENTRENT, MAIS SEULEMENT POUR ETRE VUS (2026-09-10) ---------- + # + # L'Icinga du site ne connaissait que ses sept VM. Mesure : depuis `site-mon-01`, les + # trois hyperviseurs ET sa propre passerelle rendaient 100 % de perte au ping. La + # machine qui PORTE les VM etait invisible de la supervision qui les surveille. + # + # D-48 le permet, et le dit : « les hyperviseurs sont gerables par Ansible ; hors + # flotte ne vaut que pour les commutateurs et la frontiere ». Ils peuvent donc porter + # les integrations d'observation, ce qui vaut infiniment mieux qu'un ping : charge, + # disque, unites systemd en echec, certificat. + # + # ILS N'ENTRENT PAS DANS LE SOCLE, ET C'EST LE POINT DELICAT. Un hyperviseur Proxmox + # n'est pas une VM de la flotte : lui appliquer `serveur_durci` reecrirait son + # pare-feu, son SSH et ses sysctl — sur la machine qui tient tout le reste. Ils sont + # donc places dans leur PROPRE groupe, hors de `GROUPE_SOCLE` et hors de + # `GROUPE_FLOTTE` : `make site-appliquer GROUPE=serveur_durci` ne les touchera jamais, + # parce qu'ils n'y sont pas. + # + # Ce que ca coute a dire : un groupe qui recoit des roles doit etre nomme a chaque + # fois. C'est le prix d'un perimetre explicite, et il est moins cher qu'un + # durcissement applique par megarde a un hyperviseur. + # L'adresse du temoin, derivee comme tout le reste : l'hote qui porte `serveur_icinga`. + _ip_icinga = "" + for _n_app, _a_app in applications.items(): + if str(_a_app.get("groupe")) == "serveur_icinga": + _s = serveurs.get(_a_app.get("hote")) or {} + _ip_icinga = str(_s.get("ip") or "") + break + + _hyperviseurs = {} + for _h in U.hotes(u): + if str(_h.get("role")) != "hyperviseur": + continue + _nom, _ip = str(_h.get("nom") or ""), str(_h.get("ip") or "") + if not _nom or not _ip: + continue + # UNE SEULE ADRESSE PAR HYPERVISEUR : l'underlay en declare trois par machine + # (administration, transport, fabric). On retient celle du plan d'ADMINISTRATION, + # la seule que le poste et le runner routent — les autres ne sont pas joignables + # d'ou l'on parle. + if _nom in _hyperviseurs: + continue + if not _ip.startswith("192.168.11."): + continue + _hyperviseurs[_nom] = _ip + + for _nom, _ip in sorted(_hyperviseurs.items()): + hostvars[_nom] = { + "ansible_host": _ip, + "ansible_user": UTILISATEUR_DEFAUT, + "setops_site": True, + "setops_plan_dir": str(plan_dir() or ""), + # PAS DE PARE-FEU DERIVE : aucun `nftables_baseline_ruleset_genere`. Un + # hyperviseur porte son propre filtrage, pose par Proxmox et par l'exploitant. + # + # LE TEMOIN SE JOINT PAR SON ADRESSE, PAS PAR SON NOM. Le porteur de sante vise + # `.`, ce qui suppose le plancher `/etc/hosts` du socle — que ces + # machines n'ont pas, et ne doivent pas avoir : Proxmox s'en sert pour l'identite + # de noeud du cluster. Mesure : `curl: (6) Could not resolve host` sur les trois. + **({"client_sante_icinga_url": f"https://{_ip_icinga}:5665"} + if _ip_icinga else {}), + **communes, + } + groupes.setdefault("hyperviseurs", []).append(_nom) + # ON TIRE, ON NE POUSSE PAS — ET C'EST LA ROUTE GELEE QUI LE DECIDE (2026-09-10). + # + # Toute la supervision de Set-OPS est PASSIVE : chaque noeud mesure et pousse. Ici + # c'est impossible, et la mesure le dit sans ambiguite : + # + # asgard -> 10.0.36.11 via 192.168.11.254 (le routeur du site) + # route par defaut via 192.168.11.254 GELEE (D-57) + # + # Un hyperviseur envoie vers un routeur qui ne connait pas les reseaux du site. + # Le porteur de sante expirait donc en 20 s. Le remede evident — router 10.0.0.0/8 + # par la frontiere — touche la route par defaut d'un hyperviseur EN SERVICE, ce que + # D-57 interdit : « risquer de perdre l'hyperviseur ET le chemin pour le reparer ». + # + # LE SENS INVERSE, LUI, EST OUVRABLE : la frontiere route deja `serveur_ops_site` + # vers `SETOPS_FABRIC`. Prometheus TIRE donc les metriques, et rien ne pousse. + # + # CE QU'ON NE PERD PAS EN ECHANGE. `node_exporter` expose 1005 unites systemd avec + # leur etat — exactement ce que la sonde `sante` mesurait, plus la charge, le + # disque et l'horloge. On perd le mecanisme du `ttl` (le SILENCE qui alerte) ; + # Prometheus le remplace par sa propre cible `down`, que la sonde `collecte` + # rapporte deja. + groupes.setdefault("client_metrique", []).append(_nom) + out: dict = {g: {"hosts": sorted(set(h))} for g, h in groupes.items()} out["site"] = {"hosts": sorted(hostvars)} out["_meta"] = {"hostvars": hostvars}