diff --git a/CHANGELOG.md b/CHANGELOG.md index d88fe87..360e301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,73 @@ # CHANGELOG — Set-OPS +## 2026-09-20 (4) — Le code sur le disque n'est pas le code servi + +**82 preuves dans le rapport du 17 septembre, non rejouées ici.** Les trois runners +portaient un moteur périmé, et leurs consoles servaient un moteur plus vieux encore. +Deux causes distinctes, l'une dans la chaîne de distribution, l'autre dans le rôle. + +### Une forge en retard, et trois runners qui ne tirent que lorsqu'on les rejoue + +Mesure du 20 septembre : la forge du site portait `19bbadf` (16 septembre) pour les six +dépôts, le poste `6367d85`. Le runner du site était à `504840f` — vingt-trois commits en +arrière — et les deux runners de locataire à `8456d29`, quinze en arrière. Aucun écart de +contenu : chaque révision trouvée était un ancêtre du poste, donc du retard seul. +`make genome-etat` le disait déjà, et nommait le remède. Les commits manquants touchaient +`scripts/inventory_gui.py`, dont *« la console dit sa portée »* et *« la page cesse de +proposer ce que le serveur refuse »* — ce que l'exploitant voyait à l'écran. + +`make genome-pousser` remet la forge au niveau du poste ; rejouer `serveur_ops` remet les +clones au niveau de la forge. **Rien ne tire tout seul** : aucune minuterie ne rafraîchit +un runner, et c'est assumé, mais l'écart ne se signalait nulle part entre deux rejeux. + +### Le handler existait, il n'était branché que sur le fichier d'unité + +Après les déploiements, les trois consoles portaient `6367d85` sur leur disque et +servaient toujours le code chargé le 16 septembre à 16h36. La cause tient en deux lignes : +le clone du génome n'était enregistré que pour ses tentatives, sans `notify`, et la tâche +de service demande `state: started`, qui ne fait rien quand le service tourne déjà. +`python3 scripts/inventory_gui.py` lit son code au démarrage, et seulement là — le même +piège que le certificat renouvelé qu'un nginx non rechargé continue de servir périmé. + +Le rôle retient désormais si le **moteur** a avancé — `serveur_ops_depot_moteur`, jamais +un chemin recopié — et redémarre la console dans ce seul cas. Un plan qui avance ne coupe +pas les pages ouvertes. L'expression du `when` est éprouvée sur cinq cas, dont le mode +`--check` où la boucle ne rend aucun résultat. + +### Une sonde qui ignore son mode fabrique une panne permanente + +`console-ops` rendait `exit 2` sur les deux runners de locataire — *« LE VESTIBULE EST +OUVERT : deployer, creer et raser sont a la portee de qui trouve l'adresse »* — sur des +consoles parfaitement fermées. Elle frappait `127.0.0.1:8090`, le seul endroit d'où, en +mode `oidc`, un 200 est le résultat **attendu** : nginx y est réduit à la boucle locale +précisément pour qu'on ne puisse pas contourner la passerelle SSO. Mesure du jour : +`ss` et la configuration déployée concordent, `oauth2-proxy` écoute sur `*:4180` et rend +302 vers Keycloak à une requête anonyme. + +La sonde connaît maintenant son mode. En `locale`, nginx **est** le vestibule et doit +refuser : le verdict ne change pas. En `oidc`, la serrure n'est pas un code HTTP mais une +adresse d'écoute — toute écoute du port public hors de la boucle locale est la panne, et +le refus de la passerelle reste mesuré par la sonde `passerelle` de +`serveur_oauth2_proxy`, posée sur le même hôte. Deux sondes, deux serrures, aucune qui +devine le port de l'autre. + +**Une panne permanente finit par ne plus être lue.** Un instrument qui ne sait pas d'où +il mesure invente des défauts, et use la confiance qu'on met dans les vrais. + +### Ce qui a été vérifié, et ce qui ne l'a pas été + +`--syntax-check` sur `playbooks/groupes/serveur_ops.yml` et `ansible-lint` sur le rôle +(profil `production`, 0 échec, 0 avertissement, 14 fichiers). Le gabarit de la sonde est +rendu dans ses deux modes, sans reste Jinja, et passe `bash -n` ; son filtre d'écoute est +éprouvé sur cinq formes d'adresses réelles. Le rôle est appliqué aux trois runners et la +sonde rejouée sur chacun. + +**Le redémarrage automatique n'a pas été observé en conditions réelles** : les clones +étaient déjà au niveau de la forge, la tâche s'est donc correctement abstenue. La preuve +du déclenchement viendra au prochain génome poussé. Les trois consoles ont été redémarrées +à la main ce jour-là pour servir le code courant. Aucune preuve du harnais n'a été +rejouée, aucune voûte ouverte, aucune VM créée ou détruite. + ## 2026-09-20 (3) — La capacité du système ne se déduit pas de la méthode **82 preuves dans le rapport du 17 septembre, non rejouées ici.** Le premier comparatif diff --git a/roles/serveur_ops/tasks/main.yml b/roles/serveur_ops/tasks/main.yml index de84792..1e94982 100644 --- a/roles/serveur_ops/tasks/main.yml +++ b/roles/serveur_ops/tasks/main.yml @@ -300,6 +300,20 @@ delay: 6 until: serveur_ops_clone is succeeded +# CE QUI A AVANCE, ET CE QUE CA OBLIGE A RELIRE (2026-09-20). +# +# SEUL LE MOTEUR PORTE LE CODE DE LA CONSOLE. Un plan qui avance ne change pas +# `inventory_gui.py` ; le redemarrer alors couperait les pages ouvertes pour rien. Le +# dossier du moteur est celui que `serveur_ops_depot_moteur` derive de la liste des +# depots — jamais un chemin recopie ici. +- name: Retenir si le moteur du génome a avancé + ansible.builtin.set_fact: + serveur_ops_moteur_avance: >- + {{ (serveur_ops_clone.results | default([])) + | selectattr('item.dest', 'equalto', serveur_ops_depot_moteur) + | selectattr('changed', 'defined') | selectattr('changed') + | list | length > 0 }} + # --- LES COLLECTIONS, SANS DEPENDRE DE GALAXY --------------------------------- # # UN POSTE D'EXPLOITATION QUI APPELLE galaxy.ansible.com POUR SE CONSTRUIRE N'EST PAS @@ -675,6 +689,28 @@ - serveur_ops_gui_actif | bool - not ansible_check_mode +# LE CODE NEUF SUR LE DISQUE N'EST PAS LE CODE SERVI (2026-09-20). +# +# `state: started` ne fait rien quand le service tourne deja, et le clone du genome ne +# notifiait rien. Un runner pouvait donc porter le moteur du jour et servir, depuis sa +# memoire, celui d'il y a quatre jours. Mesure de ce jour-la sur les trois runners : +# moteur 6367d85 sur le disque, console demarree le 16 septembre a 16h36. Le handler +# « Redemarrer la console Set-OPS » existait deja ; il n'etait branche que sur le +# fichier d'unite, qui lui ne changeait pas. +# +# LE PIEGE EST CONNU AILLEURS : un certificat renouvele qu'un nginx non recharge continue +# de servir perime. Ecrire ne suffit pas — il faut que le consommateur relise. +# `python3 scripts/inventory_gui.py` lit son code au demarrage, et seulement la. +- name: Redémarrer la console quand le moteur a avancé + ansible.builtin.systemd: + name: setops-gui.service + state: restarted + daemon_reload: true + when: + - serveur_ops_gui_actif | bool + - serveur_ops_moteur_avance | default(false) | bool + - not ansible_check_mode + # UN RUNNER QUI CESSE DE PUBLIER SA CONSOLE DOIT L'ETEINDRE. Sans ce retrait, retirer # l'exposition du plan laisserait le service tourner et le vestibule ouvert — la console # resterait joignable alors que plus rien ne la declare. diff --git a/roles/serveur_ops/templates/sonde-console-ops.sh.j2 b/roles/serveur_ops/templates/sonde-console-ops.sh.j2 index 5d84bfc..e8fc2be 100644 --- a/roles/serveur_ops/templates/sonde-console-ops.sh.j2 +++ b/roles/serveur_ops/templates/sonde-console-ops.sh.j2 @@ -6,10 +6,33 @@ # PAS UNE ENVELOPPE DE GREFFON, ET C'EST ASSUME. `check_http` sait dire « ce service # repond ». La verite d'ici est l'inverse : « ce service REFUSE-t-il encore ceux qui # n'ont pas de quoi entrer ». Aucun greffon standard ne pense a mesurer une serrure. +# +# ═══ LA SERRURE N'EST PAS LA MEME DES DEUX COTES (2026-09-20) ═══ +# +# Cette sonde a crie au trou de securite pendant cinq jours sur les deux runners de +# locataire : « LE VESTIBULE EST OUVERT », HTTP 200, sur des consoles qui etaient +# parfaitement fermees. Elle frappait `127.0.0.1:{{ serveur_ops_gui_port_public }}` — +# le seul endroit d'ou, en mode `oidc`, un 200 est le resultat ATTENDU. +# +# `locale` — nginx EST le vestibule. Il ecoute pour l'edge, et c'est lui qui demande +# le mot de passe. Un 200 anonyme est alors une porte ouverte : une panne. +# `oidc` — la passerelle SSO est le vestibule, et nginx est reduit a la boucle +# locale pour qu'on ne puisse pas la contourner. Un 200 depuis la boucle +# locale prouve seulement que le relais fonctionne. Ce qu'il faut mesurer +# ici, c'est que PERSONNE D'AUTRE que cette boucle n'atteint ce port. +# +# L'INSTRUMENT DOIT DIRE D'OU IL MESURE. Une sonde qui ne connait pas le mode du service +# qu'elle surveille fabrique des pannes, et une panne permanente finit par ne plus etre +# lue du tout. +# +# CE QU'ELLE NE MESURE PAS, ET POURQUOI. En `oidc`, le refus de la passerelle elle-meme +# est l'affaire de la sonde « passerelle » de `serveur_oauth2_proxy`, posee sur ce meme +# hote. Deux sondes, deux serrures, aucune qui devine le port de l'autre. set -uo pipefail GUI={{ serveur_ops_gui_ecoute }}:{{ serveur_ops_gui_port }} VESTIBULE=127.0.0.1:{{ serveur_ops_gui_port_public }} +PORT_PUBLIC={{ serveur_ops_gui_port_public }} NOM='{{ serveur_ops_hostname }}' MODE='{{ serveur_ops_gui_auth }}' @@ -20,28 +43,44 @@ if [[ "${code}" != "200" ]]; then exit 2 fi -# 2. LE VESTIBULE REFUSE-T-IL ? On frappe SANS justificatif, avec le bon `Host` : c'est -# ainsi que nginx choisit son vhost, et sans cet en-tete on mesurerait le serveur par -# defaut — c'est-a-dire autre chose. +# 2. LE VESTIBULE. On frappe SANS justificatif, avec le bon `Host` : c'est ainsi que nginx +# choisit son vhost, et sans cet en-tete on mesurerait le serveur par defaut — c'est-a-dire +# autre chose. +vu=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \ + -H "Host: ${NOM}" "http://${VESTIBULE}/" 2>/dev/null) + +if [[ "${MODE}" == "oidc" ]]; then + # EN `oidc`, LA SERRURE EST UNE ADRESSE D'ECOUTE, PAS UN CODE HTTP. + # + # `ss -H -lnt` rend une ligne par ecoute, sans en-tete ; la 4e colonne porte l'adresse. + # Tout ce qui n'est pas la boucle locale est une seconde porte sur la console, ouverte + # a qui atteint l'hote — `deployer`, `creer` et `raser` sans passer par Keycloak. + hors_boucle=$(ss -H -lnt "sport = :${PORT_PUBLIC}" 2>/dev/null \ + | awk '{print $4}' | grep -vE '^(127\.0\.0\.1|\[::1\]):' || true) + if [[ -n "${hors_boucle}" ]]; then + echo "LE VESTIBULE ECOUTE HORS DE LA BOUCLE LOCALE (${hors_boucle//$'\n'/, }) : en mode oidc la passerelle SSO est la seule porte, et ce port-la la contourne." + exit 2 + fi + if [[ "${vu}" == "200" ]]; then + echo "Console vivante, vestibule (oidc) reduit a la boucle locale : le relais repond ici, et ce port n'est joignable de nulle part ailleurs." + exit 0 + fi + echo "Le relais local ne sert plus la console : HTTP ${vu:-aucune reponse} sur ${VESTIBULE}. La passerelle SSO n'a plus rien a relayer." + exit 2 +fi + +# EN `locale`, NGINX EST LE VESTIBULE : c'est lui qui doit refuser. # # UN 200 ICI EST LA PIRE DES REPONSES. Il veut dire que le vestibule laisse entrer : la # console est alors ouverte a quiconque atteint l'adresse, avec `deployer`, `creer` et # l'edition du plan. On le traite comme une panne, parce que c'en est une. -vu=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 \ - -H "Host: ${NOM}" "http://${VESTIBULE}/" 2>/dev/null) case "${vu}" in 401|403) echo "Console vivante, vestibule (${MODE}) en place : une requete anonyme rend HTTP ${vu}." exit 0 ;; 30[12478]) - # Une passerelle SSO renvoie vers son fournisseur d'identite : c'est un refus, dit - # autrement. On l'accepte seulement quand c'est le mode declare — en `locale`, une - # redirection signifierait que le `auth_basic` n'est plus applique. - if [[ "${MODE}" != "locale" ]]; then - echo "Console vivante, vestibule (${MODE}) en place : une requete anonyme est renvoyee (HTTP ${vu})." - exit 0 - fi + # Une redirection en mode locale signifie que le `auth_basic` n'est plus applique. echo "LE VESTIBULE NE DEMANDE PLUS RIEN : en mode locale, une requete anonyme devrait rendre 401, elle rend ${vu}." exit 2 ;;