diff --git a/CHANGELOG.md b/CHANGELOG.md index 42facbc..252579f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,104 @@ # CHANGELOG — Set-OPS +## 2026-09-10 (5) — Le site prend sa propre pile d'observabilite + +Suite directe de D-87. La decision disait : *l'hebergeur n'a pas le droit de voir les +journaux de ses locataires.* Elle avait une face cachee — **a force de refuser de voir +ceux des autres, le site s'etait prive des siens.** Ses sept machines n'expediaient nulle +part. + +Le remede n'est pas d'assouplir la frontiere, c'est de donner au site **sa** pile : +`prometheus`, `loki` et `grafana` sur `site-mon-01`, pour les machines du site, sans +aucun lien avec ceux d'un tenant. + +### Ce qui bloquait etait deja documente, dans le plan lui-meme + +`10-intrants.yml` exemptait toutes les machines du site de `client_metrique` et +`client_journal`. La prose de l'exemption portait sa propre condition de levee : + +> *« Le jour ou le site prend un Loki et un Prometheus, on retire ces deux lignes. »* + +Ce jour-la etant venu, l'exemption s'est refermee toute seule. Elle aura tenu cinq jours. + +### Grafana au site n'a pas de SSO, et le role l'ignorait + +Le site n'a ni Keycloak ni `domaines.yml` — l'identite ne monte pas dans le site, c'est +D-87. `serveur_grafana` reclamait pourtant l'IdP **avant** de regarder s'il en voulait un : +le deploiement echouait sur un registre absent, pour deriver une URL qu'aucun gabarit +n'allait ecrire. L'interrupteur `serveur_grafana_oidc_actif` existait, il n'etait pas honore. + +Trois corrections, dont deux depassent le site : + +- `resoudre_idp` n'est appele que si le SSO est actif ; +- le role **refuse** SSO eteint *et* formulaire local eteint — la combinaison deploie un + Grafana en parfaite sante ou personne ne peut entrer ; +- `GF_AUTH_DISABLE_LOGIN_FORM` sort du `{% if %}` du SSO. Il y disparaissait quand le SSO + etait eteint, et c'est le defaut amont qui decidait en silence. *Un reglage + d'authentification qu'aucun fichier n'ecrit est un reglage que personne ne peut relire.* + +### Deux manques se cachaient l'un l'autre dans le devis de la frontiere + +Prometheus voyait **1 cible sur 7**. Les regles d'hote etaient justes ; c'est le devis +OPNsense qui avait deux trous, et le second masquait le premier. + +**1. Le devis ne connaissait pas les integrations universelles.** Il derivait les groupes +d'une machine du site de `applications.yml` seul — qui declare les *services*. Il ne voyait +donc ni `client_metrique`, ni `client_journal`, ni `client_pki`, ni `client_sante`, alors +que ces groupes portent des flux et **ouvrent des ports d'ecoute**. La source est desormais +l'inventaire du site, seule autorite sur ce qu'une machine porte vraiment. + +**2. Une sortie vers un role du site visait « l'exterieur ».** Symetrique du correctif du +2026-09-02, qui n'avait traite que l'entree. `!SETOPS_INTERNES` est la bonne destination +quand le pair est lointain ; quand il **nomme un role du site**, elle dit exactement +l'inverse du flux declare — elle exclut la seule machine visee : + + client_journal -> !SETOPS_INTERNES port 3100 + +Le devis autorisait a expedier les journaux du site a n'importe quel Loki du monde, et a +nul autre endroit qu'a celui-la. Neuf flux etaient dans ce cas : PKI, sante, resolveur, +sauvegarde, courriel de la forge, base d'Icinga. + +**Et les deux declarations ne font qu'une regle.** `appliquer_opnsense` pose tout en +`direction: in` (D-61) : deux regles qui ne different que par leur `sens` sont le meme +filtre pose deux fois. Dedoublonnage sur ce que la frontiere applique vraiment — la forme +`ingress` gagne, pour ne pas retirer-puis-recreer des regles deja justes. + +Devis : **49 regles a creer, 5 a retirer** — les cinq etant exactement les sorties trop +larges dont la jumelle etroite existe deja. + +### Les deux agents se supervisent enfin eux-memes + +`client_metrique` et `client_journal` etaient parmi les groupes sans sonde. Ils en ont une. + +`metriques` interroge **l'endroit que Prometheus interroge**, pas le gestionnaire de +services : un node_exporter actif mais muet est vert pour systemd. Elle declare aussi les +dix collecteurs qui cherchent du materiel qu'une VM n'a pas (`zfs`, `mdadm`, `infiniband`…) +— ils echouent identiquement sur les sept machines, et *une sonde rouge partout est une +sonde qu'on cesse de lire.* + +`journaux` ne demande pas si Alloy tourne : elle lit ce qu'il a du **jeter**, et le compare +au passage precedent — ce qui augmente est une perte en cours, ce qui stagne est une +cicatrice. + +Elle a fait ses preuves le jour meme. Sur six machines : + + Alloy: active (running) /-/ready: 200 dropped_entries_total: 50 + +Trois indicateurs verts, cinquante lignes perdues. La regle de frontiere manquait encore. + +### Etat mesure + +| | | +|---|---| +| `metriques` | **7/7 vert** | +| `journaux` | **1/7** — les six autres attendent la regle de frontiere, et le disent | +| cibles Prometheus | 2/8 pour la meme raison | +| `make prouver` | 64 OK, 0 echec | +| `ansible-lint` | 0 defaut, profil `production` | + +**Reste a la main de l'exploitant** : `make frontiere-appliquer CONFIRMER=true`, et le +secret `vault_grafana_admin` a deposer dans `underlay.vault.yml`. + ## 2026-09-10 (4) — Ce que l'hebergeur n'a pas le droit de VOIR (D-87) Question posee : *« quels roles ne dois-je pas embarquer dans le site ? »* diff --git a/docs/audit/preuve-2026-09-10.md b/docs/audit/preuve-2026-09-10.md index 897b082..7d255dd 100644 --- a/docs/audit/preuve-2026-09-10.md +++ b/docs/audit/preuve-2026-09-10.md @@ -55,14 +55,14 @@ | 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). | | 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, 118 regle(s) du site. | +| 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. | | 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 183 regles `pass`), tous non consignes et tous motives. | +| 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 216 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 | @@ -76,7 +76,7 @@ | P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 47 champ(s) sur 6 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:5, 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 | 20 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_pki/certificat, serveur_artefacts/cache-apt, serveur_artefacts/cache-apt-vol | +| P64 | Sondes de supervision : declarees ET deposees | — | ✅ OK | 22 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, serveur_ | ## Couverture des affirmations ✅ du registre diff --git a/roles/client_journal/defaults/main.yml b/roles/client_journal/defaults/main.yml index 7bf487d..80a3e8c 100644 --- a/roles/client_journal/defaults/main.yml +++ b/roles/client_journal/defaults/main.yml @@ -33,3 +33,11 @@ client_journal_loki_ca: "/etc/step/certs/root_ca.crt" # qu'aucun controle ne peut donc voir la collision. client_journal_http_port: 12346 client_journal_http_adresse: "127.0.0.1" + +# --- Sonde de supervision (voir meta/supervision.yml) --- +# L'interface de diagnostic d'Alloy, la ou il tient le compte de ce qu'il a expedie et de +# ce qu'il a jete. Locale : personne d'autre n'a a la joindre. +client_journal_sonde_url: >- + http://{{ client_journal_http_adresse }}:{{ client_journal_http_port }} +# La memoire d'un passage a l'autre : sans elle, un compteur cumulatif ne dit pas QUAND. +client_journal_sonde_etat: "/var/lib/setops/journaux-jetees" diff --git a/roles/client_journal/meta/supervision.yml b/roles/client_journal/meta/supervision.yml new file mode 100644 index 0000000..c6ce14a --- /dev/null +++ b/roles/client_journal/meta/supervision.yml @@ -0,0 +1,25 @@ +--- +# Supervision derivee du role. Voir docs/supervision-conception.md. +# +# CE QUE CETTE SONDE MESURE : les journaux SORTENT-ILS ? Pas « Alloy tourne », pas +# « le port de Loki repond » — un connect() ne prouve rien, et cette lecon a deja ete +# payee ici. Alloy tient lui-meme le compte de ce qu'il a du JETER : c'est la seule +# mesure qui distingue « rien a envoyer » de « rien n'est parti ». +# +# CE QUE CA A DEJA ATTRAPE, le jour meme ou la sonde a ete ecrite (2026-09-10) : les six +# machines du site hors de la zone de Loki affichaient +# loki_write_dropped_entries_total{reason="ingester_error"} 50 +# pendant que leur Alloy etait `active (running)` et que son `/-/ready` rendait 200. La +# frontiere n'avait pas encore la regle. Trois indicateurs verts, cinquante lignes de +# journal perdues. +# +# POURQUOI UNE MEMOIRE D'UN PASSAGE A L'AUTRE. Un compteur cumulatif ne dit pas QUAND. +# Une machine qui a perdu cinquante lignes il y a un mois et plus rien depuis n'est pas +# en panne — la reparer une seconde fois n'a pas de sens. On compare donc au passage +# precedent : ce qui AUGMENTE est une perte en cours, ce qui stagne est une cicatrice. +sondes: + - nom: journaux + ttl: 5400 + raison: >- + Les journaux de cette machine partent-ils vraiment vers Loki ? Alloy peut tourner, + se declarer pret, et jeter chaque ligne faute de pouvoir joindre le collecteur. diff --git a/roles/client_journal/tasks/main.yml b/roles/client_journal/tasks/main.yml index 1a945b3..ce21093 100644 --- a/roles/client_journal/tasks/main.yml +++ b/roles/client_journal/tasks/main.yml @@ -91,3 +91,33 @@ name: "{{ client_journal_service }}" enabled: true state: started + +# LA SONDE VIT AVEC LE ROLE QU'ELLE MESURE (docs/supervision-conception.md). Le porteur +# `client_sante` fait tourner tout ce qui vit dans ce repertoire ; il n'a pas a savoir ce +# que celle-ci mesure. +- name: Assurer le repertoire des sondes de supervision + ansible.builtin.file: + path: /usr/local/lib/setops/sondes + state: directory + owner: root + group: root + mode: "0755" + +# La sonde compare le compteur de lignes jetees a celui du passage precedent : il lui +# faut un endroit ou s'en souvenir. `/var/lib`, parce que ce n'est pas un cache — le +# perdre ferait passer une cicatrice ancienne pour une perte en cours. +- name: Assurer le repertoire d etat des sondes + ansible.builtin.file: + path: /var/lib/setops + state: directory + owner: root + group: root + mode: "0750" + +- name: Deposer la sonde d expedition des journaux + ansible.builtin.template: + src: sonde-journaux.sh.j2 + dest: /usr/local/lib/setops/sondes/journaux.sh + owner: root + group: root + mode: "0750" diff --git a/roles/client_journal/templates/sonde-journaux.sh.j2 b/roles/client_journal/templates/sonde-journaux.sh.j2 new file mode 100644 index 0000000..c051cf8 --- /dev/null +++ b/roles/client_journal/templates/sonde-journaux.sh.j2 @@ -0,0 +1,52 @@ +#!/bin/bash +# GENERE par Set-OPS (role client_journal). Ne pas editer a la main. +# +# SONDE « journaux » — les lignes SORTENT-ELLES vers Loki ? +# +# Contrat : docs/supervision-conception.md (API des greffons Nagios) — une ligne, 0/1/2. +# Mise en defaut PAR PARAMETRE : `client_journal_sonde_url` (port ferme). +set -uo pipefail + +URL={{ client_journal_sonde_url }} +ETAT={{ client_journal_sonde_etat }} + +corps=$(curl -s --max-time 10 "${URL}/metrics" 2>/dev/null) +if [[ -z "${corps}" ]]; then + echo "Alloy ne repond pas sur ${URL} : impossible de savoir si les journaux partent." + exit 2 +fi + +# CE QU'ALLOY A DU JETER. On somme tous les motifs : `ingester_error` (le collecteur +# refuse ou reste injoignable), `rate_limited`, `queue_is_full`… La distinction interesse +# le diagnostic, pas la question « des lignes sont-elles perdues ? ». +jetees=$(printf '%s\n' "${corps}" \ + | awk '/^loki_write_dropped_entries_total\{/ {s += $2} END {printf "%d", s+0}') +envoyees=$(printf '%s\n' "${corps}" \ + | awk '/^loki_write_sent_entries_total\{/ {s += $2} END {printf "%d", s+0}') + +# LA MEMOIRE D'UN PASSAGE : sans elle, un compteur cumulatif ne dit pas QUAND. +precedent=0 +[[ -r "${ETAT}" ]] && precedent=$(cat "${ETAT}" 2>/dev/null | tr -cd '0-9') +: "${precedent:=0}" +printf '%s' "${jetees}" > "${ETAT}" 2>/dev/null || true + +perf="envoyees=${envoyees} jetees=${jetees}" +nouvelles=$(( jetees - precedent )) + +if (( nouvelles > 0 )); then + echo "PERTE EN COURS : ${nouvelles} ligne(s) de journal jetees depuis le dernier passage (${jetees} au total). | ${perf}" + exit 2 +fi +# UN COMPTEUR QUI RECULE veut dire qu'Alloy a redemarre — ses compteurs repartent de +# zero. Ce n'est pas une perte, c'est une remise a zero, et la dire pour ce qu'elle est +# evite de chercher une panne la ou il y a eu un redemarrage. +if (( nouvelles < 0 )); then + echo "Alloy a redemarre (compteurs remis a zero) ; ${jetees} ligne(s) jetees depuis. | ${perf}" + exit 1 +fi +if (( jetees > 0 )); then + echo "Journaux expedies ; ${jetees} ligne(s) perdues par le passe, plus rien depuis. | ${perf}" + exit 1 +fi +echo "Journaux expedies vers Loki, aucune ligne perdue. | ${perf}" +exit 0 diff --git a/roles/client_metrique/defaults/main.yml b/roles/client_metrique/defaults/main.yml index c55fe02..6b50f58 100644 --- a/roles/client_metrique/defaults/main.yml +++ b/roles/client_metrique/defaults/main.yml @@ -42,3 +42,28 @@ client_metrique_tls_dir: "/etc/prometheus-node-exporter/tls" client_metrique_web_config: "/etc/prometheus-node-exporter/web-config.yml" client_metrique_tls_source_cert: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.crt" client_metrique_tls_source_cle: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.key" + +# --- Sonde de supervision (voir meta/supervision.yml) --- +# L'ENDROIT QUE PROMETHEUS INTERROGE, vu depuis la machine elle-meme. Le port se derive +# de l'adresse d'ecoute declaree : ecrire `9100` ici le ferait mentir le jour ou on la +# change, et une sonde qui vise le mauvais port rapporte une panne qui n'existe pas. +client_metrique_sonde_schema: "{{ 'https' if client_metrique_tls_actif | bool else 'http' }}" +client_metrique_sonde_port: "{{ client_metrique_adresse_ecoute | regex_replace('^.*:', '') }}" +client_metrique_sonde_url: >- + {{ client_metrique_sonde_schema }}://127.0.0.1:{{ client_metrique_sonde_port }}/metrics + +# LES COLLECTEURS QUI CHERCHENT DU MATERIEL ABSENT D'UNE VM (mesure du 2026-09-10 : les +# dix memes echouent sur les sept machines du site). Leur echec n'est pas une panne, et +# une sonde rouge partout est une sonde qu'on cesse de lire. Sur du METAL, vider cette +# liste : `mdadm` ou `bonding` qui tombe y voudrait dire quelque chose. +client_metrique_collecteurs_sans_objet: + - bonding + - fibrechannel + - infiniband + - ipvs + - mdadm + - nfs + - nfsd + - pressure + - tapestats + - zfs diff --git a/roles/client_metrique/meta/supervision.yml b/roles/client_metrique/meta/supervision.yml new file mode 100644 index 0000000..54eb273 --- /dev/null +++ b/roles/client_metrique/meta/supervision.yml @@ -0,0 +1,26 @@ +--- +# Supervision derivee du role. Voir docs/supervision-conception.md. +# +# CE QUE CETTE SONDE MESURE. La question utile n'est pas « node_exporter tourne-t-il ? » +# — un service actif qui ne rend aucune metrique est vert pour systemd et muet pour +# Prometheus. C'est « cette machine RAPPORTE-T-ELLE ? », posee la ou vit la verite : +# a l'endroit meme que Prometheus interroge. +# +# CE QU'ON NE MESURE PAS ICI : que Prometheus l'ait effectivement recu. Ca, c'est la +# sonde `collecte`, chez le serveur — et c'est le bon partage. Une machine ne peut pas +# temoigner de ce qui arrive a l'autre bout ; elle temoigne de ce qu'elle offre. +# +# LE MATERIEL ABSENT N'EST PAS UNE PANNE (mesure du 2026-09-10). Dix collecteurs +# echouent identiquement sur les sept machines du site — `zfs`, `mdadm`, `infiniband`, +# `bonding`… Ils cherchent du materiel qu'une VM n'a pas. Une sonde qui crierait la +# serait rouge partout, tout le temps, et on apprendrait a ne plus la lire. Ils sont +# donc DECLARES sans objet, dans une liste qu'une machine de metal peut vider. +sondes: + - nom: metriques + # Six fois la periode du porteur (15 min) : cinq passages peuvent manquer avant + # qu'Icinga ne perime. On ne crie pas pour un hoquet, on crie pour un silence. + ttl: 5400 + raison: >- + Cette machine offre-t-elle vraiment ses metriques ? Un node_exporter actif mais + muet — ou dont un collecteur ATTENDU s'est mis a echouer — laisse Prometheus + collecter du vide sans que rien ne le dise. diff --git a/roles/client_metrique/tasks/main.yml b/roles/client_metrique/tasks/main.yml index 7c94ff2..7391b06 100644 --- a/roles/client_metrique/tasks/main.yml +++ b/roles/client_metrique/tasks/main.yml @@ -121,3 +121,22 @@ name: "{{ client_metrique_service }}" enabled: true state: started + +# LA SONDE VIT AVEC LE ROLE QU'ELLE MESURE (docs/supervision-conception.md). Le porteur +# `client_sante` fait tourner tout ce qui vit dans ce repertoire ; il n'a pas a savoir ce +# que celle-ci mesure. +- name: Assurer le repertoire des sondes de supervision + ansible.builtin.file: + path: /usr/local/lib/setops/sondes + state: directory + owner: root + group: root + mode: "0755" + +- name: Deposer la sonde des metriques servies + ansible.builtin.template: + src: sonde-metriques.sh.j2 + dest: /usr/local/lib/setops/sondes/metriques.sh + owner: root + group: root + mode: "0750" diff --git a/roles/client_metrique/templates/sonde-metriques.sh.j2 b/roles/client_metrique/templates/sonde-metriques.sh.j2 new file mode 100644 index 0000000..13074e4 --- /dev/null +++ b/roles/client_metrique/templates/sonde-metriques.sh.j2 @@ -0,0 +1,47 @@ +#!/bin/bash +# GENERE par Set-OPS (role client_metrique). Ne pas editer a la main. +# +# SONDE « metriques » — cette machine offre-t-elle vraiment ses metriques ? +# +# Contrat : docs/supervision-conception.md (API des greffons Nagios) — une ligne, 0/1/2. +# Mise en defaut PAR PARAMETRE : `client_metrique_sonde_url` (port ferme, mauvais schema). +set -uo pipefail + +URL={{ client_metrique_sonde_url }} +# Les collecteurs qui cherchent du materiel qu'une VM n'a pas. Vider cette liste sur du +# metal : leur echec y voudrait dire quelque chose. +SANS_OBJET="{{ client_metrique_collecteurs_sans_objet | join(' ') }}" + +# ON INTERROGE L'ENDROIT QUE PROMETHEUS INTERROGE, pas le gestionnaire de services. +# `-k` parce que le certificat est celui de l'hote, emis par notre AC : le valider ici +# reviendrait a mesurer la PKI, ce que fait deja la sonde `certificat`. +corps=$(curl -sk --max-time 10 "${URL}" 2>/dev/null) +if [[ -z "${corps}" ]]; then + echo "node_exporter ne rend rien sur ${URL}." + exit 2 +fi + +lignes=$(printf '%s\n' "${corps}" | grep -c '^node_') +if (( lignes == 0 )); then + echo "node_exporter repond sur ${URL} mais n'expose AUCUNE metrique node_." + exit 2 +fi + +# LES COLLECTEURS EN ECHEC, moins ceux qu'on a declares sans objet. +en_echec=$(printf '%s\n' "${corps}" \ + | sed -n 's/^node_scrape_collector_success{collector="\([a-z_]*\)"} 0$/\1/p' | sort) +inattendus="" +for c in ${en_echec}; do + case " ${SANS_OBJET} " in + *" ${c} "*) : ;; + *) inattendus="${inattendus}${inattendus:+, }${c}" ;; + esac +done + +perf="lignes=${lignes} collecteurs_en_echec=$(printf '%s\n' "${en_echec}" | grep -c .)" +if [[ -n "${inattendus}" ]]; then + echo "Metriques servies (${lignes} lignes) mais collecteur(s) ATTENDU(S) en echec : ${inattendus} | ${perf}" + exit 1 +fi +echo "Metriques servies : ${lignes} lignes node_, aucun collecteur attendu en echec. | ${perf}" +exit 0 diff --git a/roles/serveur_grafana/tasks/main.yml b/roles/serveur_grafana/tasks/main.yml index 5e9526d..e3fcdcb 100644 --- a/roles/serveur_grafana/tasks/main.yml +++ b/roles/serveur_grafana/tasks/main.yml @@ -1,12 +1,30 @@ --- +# UNE PORTE AU MOINS. Sans SSO et sans formulaire local, Grafana demarre, se porte +# bien, et personne n'entre. Les deux reglages sont independants et se sont deja +# retrouves a `false` ensemble : la combinaison se refuse ici, pas apres le +# deploiement, quand plus aucun chemin ne mene a la console. +- name: Exiger au moins un chemin de connexion + ansible.builtin.assert: + that: + - (serveur_grafana_oidc_actif | bool) or (serveur_grafana_connexion_locale | bool) + fail_msg: >- + Ni SSO (`serveur_grafana_oidc_actif`) ni formulaire local + (`serveur_grafana_connexion_locale`) : Grafana n'aurait aucune porte d'entree. + # L'URL PUBLIQUE de l'IdP se derive de l'exposition declaree au plan, elle ne se # fabrique pas : `keycloak.` n'est publie nulle part (voir resoudre_idp). +# +# ON NE RESOUT QUE CE QU'ON UTILISE. Le role reclamait l'IdP AVANT de regarder s'il +# en voulait un : au site, qui n'a ni Keycloak ni `domaines.yml` (D-87 : l'identite +# ne monte pas), le deploiement echouait sur un registre absent — pour derive une URL +# qu'aucun gabarit n'allait ecrire. L'interrupteur existait, il n'etait pas honore. - name: Resoudre le fournisseur d'identite (role partage) ansible.builtin.include_role: name: resoudre_idp vars: resoudre_idp_realm: "{{ serveur_grafana_oidc_realm }}" + when: serveur_grafana_oidc_actif | bool - name: Exiger le mot de passe admin Grafana (Vault) ansible.builtin.assert: that: diff --git a/roles/serveur_grafana/templates/setops.conf.j2 b/roles/serveur_grafana/templates/setops.conf.j2 index 0c36853..5a74ffd 100644 --- a/roles/serveur_grafana/templates/setops.conf.j2 +++ b/roles/serveur_grafana/templates/setops.conf.j2 @@ -11,11 +11,15 @@ Environment=GF_SERVER_DOMAIN={{ serveur_grafana_hostname }} Environment=GF_SERVER_ROOT_URL={{ serveur_grafana_root_url }} Environment=GF_SECURITY_ADMIN_PASSWORD={{ serveur_grafana_admin_password }} Environment=GF_USERS_ALLOW_SIGN_UP=false -{% if serveur_grafana_oidc_actif %} -{# Masque le formulaire local. `grafana-cli admin reset-admin-password` - reste utilisable sur l'hote : le compte n'est pas supprime, il n'est - plus offert au reseau. #} +{# Le formulaire local est offert, ou il ne l'est pas — et le gabarit le DIT dans + les deux cas. Cette ligne vivait sous le `if` du SSO : SSO eteint, elle + disparaissait, et c'est le defaut de Grafana qui decidait en silence. Un + reglage d'authentification qu'aucun fichier n'ecrit est un reglage que + personne ne peut relire. + Masquer le formulaire ne supprime pas le compte : `grafana-cli admin + reset-admin-password` reste utilisable sur l'hote. #} Environment=GF_AUTH_DISABLE_LOGIN_FORM={{ 'false' if serveur_grafana_connexion_locale | bool else 'true' }} +{% if serveur_grafana_oidc_actif %} Environment=GF_AUTH_GENERIC_OAUTH_ENABLED=true Environment=GF_AUTH_GENERIC_OAUTH_NAME={{ serveur_grafana_oidc_nom }} Environment=GF_AUTH_GENERIC_OAUTH_CLIENT_ID={{ serveur_grafana_oidc_client_id }} diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 0f2df82..d28867c 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -327,7 +327,47 @@ def _instance_active() -> tuple[str, dict] | tuple[None, None]: # pour ne pas reecrire trente appels. _lire_plan_site = underlay_mod.lire_plan_site _machines_du_plan_site = underlay_mod.machines_site -_services_du_plan_site = underlay_mod.services_site +def _services_du_plan_site() -> dict[str, list[str]]: + """{machine du site: [groupes qu'elle porte]} — tels que l'INVENTAIRE les donne. + + LA SOURCE EST L'INVENTAIRE, PAS `applications.yml` SEUL (2026-09-10). + + `underlay.services_site()` lit le registre des applications : il voit `serveur_loki`, + `serveur_icinga`, `serveur_forgejo` — ce qu'une machine du site heberge COMME SERVICE. + Il ne voit pas les INTEGRATIONS UNIVERSELLES, que `site_inventaire` ajoute a toutes les + machines sans qu'aucune application ne les declare : `client_metrique`, `client_journal`, + `client_pki`, `client_sante`. + + Ces groupes-la portent pourtant des flux, et des flux qui ouvrent des PORTS D'ECOUTE : + `client_metrique` declare `ingress 9100`. Invisible au devis, la regle d'entree n'etait + jamais emise — et comme `serveur_prometheus` declare l'`egress` en face, sa regle de + sortie tombait sur `!SETOPS_INTERNES`. Le devis autorisait donc Prometheus a scruter le + monde entier sur 9100 SAUF les six machines qu'il devait scruter. + + Meme forme que le defaut du 2026-09-02 sur `serveur_icinga` : les deux declarations + etaient justes, c'est leur RENCONTRE au site qui manquait. + + Ne retient que les groupes qui sont de vrais roles — un groupe de commodite + (`site`, la flotte, le socle) n'a pas de flux et n'a pas a produire d'alias. + """ + try: + import site_inventaire # noqa: PLC0415 — le site peut ne pas etre monte + inv = site_inventaire.inventaire() + except Exception: + return underlay_mod.services_site() + hotes = set((inv.get("_meta") or {}).get("hostvars") or {}) + if not hotes: + return underlay_mod.services_site() + out: dict[str, list[str]] = {h: [] for h in hotes} + for groupe, corps in inv.items(): + if groupe == "_meta" or not isinstance(corps, dict): + continue + if not (RACINE / "roles" / str(groupe)).is_dir(): + continue + for h in (corps.get("hosts") or []): + if h in out: + out[h].append(str(groupe)) + return {h: sorted(set(g)) for h, g in out.items()} def _ports_du_plan_site() -> dict[str, str]: @@ -847,22 +887,43 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: file=sys.stderr) continue _p = [_reel] + # UNE SORTIE VERS UN ROLE DU SITE VISE CE ROLE, PAS « L'EXTERIEUR ». + # + # Symetrique du correctif du 2026-09-02, qui n'avait traite que l'entree. + # `!SETOPS_INTERNES` est la forme de « vers Internet » : c'est le bon + # rendu quand le pair est lointain (un depot Debian, un serveur NTP). Quand + # le pair NOMME UN ROLE DU SITE, cette destination dit exactement l'inverse + # de ce que le flux declare — elle exclut la seule machine visee. + # + # Mesure du 2026-09-10 : `client_journal` sortait vers `!SETOPS_INTERNES` + # sur 3100. Le Loki du site etant interne, la regle de sortie ne le couvrait + # pas ; seule la regle d'ENTREE, emise en face, faisait passer les journaux. + # Le devis autorisait donc a expedier les journaux du site a n'importe quel + # Loki du monde, et a nul autre endroit qu'a celui-la. + if _entrant: + _dests = [_a] + elif "serveur_ops_tenant" in _pairs_fl: + _dests = ["SETOPS_RUNNERS_TENANTS"] + elif "fabric" in _pairs_fl: + _dests = ["SETOPS_FABRIC"] + else: + _internes = sorted(set(_pairs_fl) & set(_roles_site)) + _dests = ([nom_alias(_x, "SITE") for _x in _internes] + if _internes else ["!SETOPS_INTERNES"]) for _src, _itf in _portees: - regles.append({ - "sens": "in" if _entrant else "out", - "interface": _itf, - "protocole": _fl.get("protocole", "tcp"), - "source": _src if _entrant else _a, - "destination": _a if _entrant else ( - "SETOPS_RUNNERS_TENANTS" if "serveur_ops_tenant" in _pairs_fl - else "SETOPS_FABRIC" if "fabric" in _pairs_fl - else "!SETOPS_INTERNES"), - "ports": _p, - "chiffrement": _fl.get("chiffrement"), - "role": _role, - "tenant": "SITE", - "raison": _fl.get("raison", ""), - }) + for _dst in _dests: + regles.append({ + "sens": "in" if _entrant else "out", + "interface": _itf, + "protocole": _fl.get("protocole", "tcp"), + "source": _src if _entrant else _a, + "destination": _dst, + "ports": _p, + "chiffrement": _fl.get("chiffrement"), + "role": _role, + "tenant": "SITE", + "raison": _fl.get("raison", ""), + }) transit = transit_underlay() saut = prochain_saut(transit) @@ -968,6 +1029,48 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: "raison": str(s.get("motif") or "bruit de journal, deja refuse par defaut"), }) + # DEUX DECLARATIONS JUSTES NE FONT QU'UNE REGLE (2026-09-10). + # + # Un flux interne au site est declare DES DEUX COTES : `client_journal` sort vers + # Loki, `serveur_loki` accepte les journaux. C'est voulu — chaque role dit ce qui le + # concerne, sans savoir qui est en face. Depuis que les deux resolvent la meme paire + # d'alias, ils produisent le meme quintuplet, et la frontiere se retrouvait avec deux + # regles identiques : le boitier les applique toutes les deux, l'exploitant se + # demande laquelle compte, et la suivante qu'on retire ne change rien. + # + # LE `sens` N'EST PAS DANS LA CLE, et c'est le coeur de l'affaire : `appliquer_opnsense` + # pose TOUTES les regles en `direction: in` (D-61 — le devis raisonne en ARRIVEE sur + # l'interface). Deux regles qui ne different que par leur `sens` sont donc, sur le + # boitier, le meme filtre pose deux fois. Seul leur marqueur differait. + # + # On dedoublonne sur ce que la frontiere applique VRAIMENT : interface, protocole, + # source, destination, ports, sequence. Pas sur la raison, qui differe selon le role + # qui l'a dite — la premiere rencontree la garde, et c'est la seule chose qu'on perd. + # C'EST LA FORME `ingress` QUI SURVIT, et ce n'est pas indifferent : le marqueur pose + # sur le boitier contient le `sens`. Laisser gagner l'un ou l'autre au hasard de + # l'ordre alphabetique des roles ferait RETIRER puis RECREER des regles deja justes — + # `serveur_forgejo -> serveur_postgresql` disparaissait pour renaitre a l'identique + # sous un autre marqueur. Une reconciliation qui remue ce qui va bien devient une + # reconciliation qu'on n'ose plus lancer. + def _cle_regle(r: dict) -> tuple: + return (r.get("interface"), r.get("protocole"), + r.get("source"), r.get("destination"), + tuple(r.get("ports") or ()), r.get("sequence"), + r.get("action"), bool(r.get("journaliser"))) + + _entrantes = {_cle_regle(_r) for _r in regles if _r.get("sens") == "in"} + _vues: set[tuple] = set() + _uniques = [] + for _r in regles: + _cle = _cle_regle(_r) + if _cle in _vues: + continue + if _cle in _entrantes and _r.get("sens") != "in": + continue + _vues.add(_cle) + _uniques.append(_r) + regles = _uniques + # Adresse du noeud de sortie primaire sur le lien de frontiere, calculee une fois : # elle sert au prochain saut des routes ET au commentaire qui l'explique. _ip_sortie = adresse_noeud_de_sortie(underlay_mod.charger(), transit) diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index df7952e..cb075b2 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -412,6 +412,19 @@ def inventaire() -> dict: # n'a ni l'un ni l'autre, et n'heberge aucune boite — il lui faut seulement # de quoi faire SORTIR une alerte, en son propre nom. "serveur_postfix_mode": "relais", + # LE GRAFANA DU SITE N'A PAS DE SSO — pas par oubli, par D-87. + # + # Le SSO d'un tenant supposerait que le site emprunte l'identite d'un + # ecosysteme qu'il doit pouvoir survivre ; un Keycloak DU site ferait + # remonter l'identite dans le site, exactement ce que D-87 refuse. Reste + # le compte local, que le site atteint par `sudo` sur l'hote — et il faut + # alors REOUVRIR le formulaire, que le mode SSO ferme. + # + # Les deux lignes vont ensemble : eteindre l'une sans allumer l'autre + # laisse un Grafana sans aucune porte. Le role refuse desormais cette + # combinaison, plutot que de la deployer. + "serveur_grafana_oidc_actif": False, + "serveur_grafana_connexion_locale": True, # Le depot du site, pour les machines du site qui y deposent. Vide s'il n'y # en a pas — `client_backup` refuse alors, plutot que de viser un nom qui # ne repond pas : mieux vaut un deploiement qui s'arrete qu'une sauvegarde