console chez les locataires, collision de noms, et un fichier ecrase

Les deux locataires declarent leur console derriere oauth2-proxy. En oidc le
vestibule n ecoute que la boucle locale : le gabarit annoncait cette protection
en s en remettant au pare-feu, qui ouvrait le port depuis l edge — la console
etait joignable sans passer par la passerelle.

COLLISION REELLE : serveur_oauth2_proxy est mono-instance par machine, et la
table des noms publics etait indexee par GROUPE. La passerelle de la vigie se
croyait la console ; son URL de retour OIDC aurait vise l autre machine. Une
application nomme un COUPLE (machine, role). P67 ne le voyait pas parce qu elle
indexait comme la derivation qu elle garde — une garde qui reproduit le
raisonnement qu elle verifie ne verifie rien.

ET J AI ECRASE group_vars/serveur_ops.yml sans le regarder : 68 lignes
detruites, deux preuves tombees, et une explication fausse construite dessus. Le
git diff qui m avait rassure portait un glob developpe par le shell parent : il
n a rien matche et n a rien dit. Une commande qui ne trouve rien et une commande
qui trouve que rien n a change rendent le meme silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-15 11:47:50 -04:00
parent 0529951381
commit e93e8f4c1d
8 changed files with 180 additions and 39 deletions

View file

@ -1,5 +1,73 @@
# CHANGELOG — Set-OPS
## 2026-09-15 (5) — La console chez les locataires, une collision de noms, et un fichier que j'ai ecrase
Les deux locataires declarent desormais leur console derriere leur SSO. Le chemin a
traverse une collision reelle et une faute de ma part.
### `console.<domaine>`, derriere oauth2-proxy
ops-01 serveur_ops_gui_actif: true, auth: oidc
le GUI sur 127.0.0.1:8765, le vestibule nginx sur 127.0.0.1:8090
la passerelle sur 4180, publiee par l'edge
`oidc` ET PAS `locale` : ces ecosystemes ont un annuaire. Cette console lance des
deploiements et peut RASER — un groupe se revoque sans deploiement (D-66), un mot de passe
partage devant ce pouvoir est un accident qui attend.
ET LE VESTIBULE N'ECOUTE PLUS QUE LA BOUCLE LOCALE EN `oidc`. Le gabarit annoncait cette
protection — *« lui seul doit pouvoir frapper ce port »* — en s'en remettant a
`meta/flux.yml`, qui ouvre le 8090 depuis `[edge, admin]`. L'edge pouvait donc joindre la
console SANS passer par la passerelle. Un port qui n'est pas ouvert ne se contourne pas ;
une regle, si.
### UNE COLLISION DE NOMS QUE LA GARDE NE VOYAIT PAS
`serveur_oauth2_proxy` est mono-instance par machine : une devant la vigie sur le noeud de
supervision, une devant la console sur le runner. La table des noms publics etait indexee
par GROUPE :
mon-01 serveur_oauth2_proxy_hostname = console.chezlepro.internal
ops-01 serveur_oauth2_proxy_hostname = console.chezlepro.internal
La passerelle de la VIGIE se croyait la console. Son URL de retour OIDC aurait vise l'autre
machine, et le SSO de la supervision serait tombe — pour un service auquel on n'avait pas
touche.
**Une application nomme un COUPLE (machine, role).** `instancier` et `site_inventaire` en
ont la clef juste desormais.
ET P67 NE LE VOYAIT PAS, parce qu'elle indexait par groupe **comme la derivation qu'elle
garde** : elle comparait la valeur a elle-meme. *Une garde qui reproduit le raisonnement
qu'elle verifie ne verifie rien.* Elle compare maintenant par machine, et elle a ete
eprouvee en lui remettant la faute sous les yeux.
### CE QUE J'AI CASSE, ET LA MESURE QUI ME L'A CACHE
J'ai ecrit `group_vars/serveur_ops.yml` avec `cat >` **sans regarder s'il existait**. Il
existait : 84 lignes chez Chezlepro, 77 chez TechnoLibre. J'en ai detruit 68 — la liste
des depots du genome, la branche `master` de TechnoLibre, l'amont de la forge, la source
de la cle de voute.
Deux preuves sont tombees (P05, P32). **J'ai d'abord accuse le retrait des forges de la
veille**, et construit une explication complete : « le site expose l'amont, le locataire
ne le porte pas ». Elle etait fausse — le fichier restaure le portait deja.
LA MESURE QUI M'A RASSURE A TORT :
(cd $e && git diff -- inventories/*/group_vars/serveur_ops.yml)
Le glob est developpe par le shell PARENT, ou `inventories/` n'existe pas. Le motif est
passe tel quel, n'a rien matche, et `git diff` n'a rien affiche. J'ai lu ce silence comme
« aucun fichier ecrase ». C'est `git status` — sans glob — qui a fini par montrer le `M`.
Une commande qui ne trouve rien et une commande qui trouve que rien n'a change rendent le
meme silence. Encore la meme forme que les six replis des deux derniers jours, cette
fois dans mes propres mains.
Restaure par `git checkout`, puis les trois lignes de la console ajoutees SANS toucher au
reste. Harnais : **77 OK**.
## 2026-09-15 (4) — La console d'exploitation est allumee, et publiee par l'edge
https://console.genese.internal/ 401 sans justificatif, la console avec

View file

@ -30,7 +30,7 @@
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 30 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 31 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 29 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 6 instance(s) federee(s), aucun index en collision. |
@ -44,7 +44,7 @@
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 33 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 22, 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 | 67 scripts expliques et atteignables, 123 cibles make documentees, 68 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 36 exigence(s) de role, toutes satisfaites (142 cle(s) declaree(s) par l'instance). |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 36 exigence(s) de role, toutes satisfaites (144 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 36 revendication(s) de port, aucune collision entre roles co-localises (35 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 46 document(s) declarent leur lecteur (41 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). |
@ -78,8 +78,8 @@
| 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 | 40 sonde(s) declaree(s) ET deposee(s), chacune avec sa raison et son `ttl` : client_journal/journaux, client_metrique/metriques, client_pki/certificat, serveur_ |
| 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 | 3 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (5 exposition(s)). |
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 12 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
| 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 (6 exposition(s)). |
| P67 | Nom public : le service porte celui du plan, pas celui du role | — | ✅ OK | 13 service(s) expose(s) portent le nom du plan, sur 2 inventaire(s) : instance, SITE. |
| P68 | Cle de depot telechargee : mesuree avant d'etre utilisee | — | ✅ OK | 5 role(s) telechargent une cle de depot, tous la mesurent avant de s'en servir. |
| P69 | Amorcage d'un tenant : l'adresse designe le site REEL | — | ✅ OK | 2 adresse(s) d'amorcage designent bien une machine du site. |
| P70 | Depot de binaires : il tient tout ce que les roles vont chercher | — | ✅ OK | 6 artefact(s) direct(s) tenus par le depot du site. |

View file

@ -60,6 +60,14 @@ vault_icingaweb2_admin: ""
# accident qui attend.
vault_setops_gui_admin: ""
# LE SECRET OIDC DE LA CONSOLE D'EXPLOITATION (mode `oidc`).
#
# Le GUI de Set-OPS n'a aucune authentification a lui : la passerelle est sa seule
# serrure, et ce secret est ce qui la lie a Keycloak. Vide chez un ecosysteme sans
# annuaire — un SITE — qui emploie alors le vestibule local.
vault_setops_console_oidc: ""
# LE COMPTE DE METRIQUES DE POSTGRESQL — lecture seule, role `pg_monitor`.
#

View file

@ -240,6 +240,22 @@ serveur_ops_gui_port: 8765
# LE PORT QUE L'EDGE VA CHERCHER. C'est nginx qui l'ouvre, jamais le GUI lui-meme.
serveur_ops_gui_port_public: 8090
# SUR QUOI CE PORT S'OUVRE — ET CA DEPEND DU VESTIBULE (2026-09-15).
#
# En `locale`, nginx EST le vestibule : il doit etre joignable par l'edge, donc il ecoute
# sur toutes les interfaces et `meta/flux.yml` ouvre le 8090 depuis `[edge, admin]`.
#
# En `oidc`, la passerelle SSO se tient devant et c'est ELLE qui est publiee. Si nginx
# restait ouvert, l'edge pourrait frapper le 8090 directement et CONTOURNER la passerelle
# — la console serait alors accessible sans authentification a qui atteint l'hote. Le
# commentaire du gabarit annoncait cette protection ; rien ne la tenait.
#
# On la tient ici, a l'ecoute, et pas par une regle : un port qui n'est pas ouvert ne se
# contourne pas, alors qu'une regle se deplace. La regle du 8090 reste declaree pour le
# mode `locale`, et devient simplement sans objet en `oidc`.
serveur_ops_gui_nginx_bind: >-
{{ '127.0.0.1:' if serveur_ops_gui_auth == 'oidc' else '' }}
# DEUX VESTIBULES, ET LE PREMIER EST LE BON.
#
# `oidc` — `serveur_oauth2_proxy` devant, donc Keycloak, donc un GROUPE d'annuaire.
@ -268,9 +284,19 @@ serveur_ops_gui_admin_motdepasse: ""
serveur_ops_gui_htpasswd: "/etc/nginx/.setops-gui.htpasswd"
serveur_ops_gui_realm: "Console Set-OPS"
# LE NOM SOUS LEQUEL L'EDGE LE SERT — derive du plan par `instancier` et par
# `site_inventaire`, comme tout nom public (P67). Le defaut n'est qu'un repli.
serveur_ops_hostname: "console.{{ domaine_interne }}"
# LE NOM SOUS LEQUEL LA CONSOLE EST SERVIE.
#
# EN `oidc`, LE PLAN LE DONNE A LA PASSERELLE, PAS AU RUNNER. C'est elle qui est publiee ;
# `serveur_ops_hostname` sert alors au `server_name` du vestibule local, qui doit
# reconnaitre l'en-tete `Host` que la passerelle relaie telle quelle. On le derive donc du
# nom de la passerelle POSEE SUR CETTE MACHINE — une seule source, pas deux conventions.
#
# EN `locale`, le plan expose le runner lui-meme et `instancier` pose directement
# `serveur_ops_hostname` : ce `default` ne s'applique alors pas.
#
# Le dernier repli garde la convention du depot, pour un ecosysteme qui n'expose rien.
serveur_ops_hostname: >-
{{ serveur_oauth2_proxy_hostname | default('console.' ~ domaine_interne, true) }}
# Le vestibule et de quoi hacher son mot de passe.
serveur_ops_gui_paquets:

View file

@ -7,8 +7,10 @@
# passer tout chemin qu'un `location` plus specifique attraperait en premier. Une
# serrure qu'on contourne par la porte d'a cote n'est pas une serrure.
server {
listen {{ serveur_ops_gui_port_public }};
listen {{ serveur_ops_gui_nginx_bind }}{{ serveur_ops_gui_port_public }};
{% if not serveur_ops_gui_nginx_bind %}
listen [::]:{{ serveur_ops_gui_port_public }};
{% endif %}
server_name {{ serveur_ops_hostname }};
{% if serveur_ops_gui_auth == 'locale' %}
@ -18,10 +20,13 @@ server {
auth_basic_user_file {{ serveur_ops_gui_htpasswd }};
{% else %}
# AVEC ANNUAIRE : c'est `serveur_oauth2_proxy` qui se tient devant, et lui seul doit
# pouvoir frapper ce port. Le pare-feu de l'hote le dit deja (`meta/flux.yml`), et on
# ne le redit pas ici : deux couches qui declarent la meme politique finissent par
# en declarer deux differentes.
# AVEC ANNUAIRE : `serveur_oauth2_proxy` se tient devant, sur la MEME machine, et
# c'est lui qui est publie. Ce vhost n'ecoute alors que la boucle locale
# (`serveur_ops_gui_nginx_bind`) — un port qui n'est pas ouvert ne se contourne pas.
#
# LA PREMIERE ECRITURE S'EN REMETTAIT AU PARE-FEU, et le pare-feu ouvrait ce port
# depuis `[edge, admin]` : l'edge pouvait donc joindre la console SANS passer par la
# passerelle. Le commentaire annoncait une protection que rien ne tenait.
{% endif %}
# LA CONSOLE DEPLOIE, ET UN DEPLOIEMENT EST LONG. Les delais par defaut de nginx

View file

@ -211,12 +211,31 @@ def generer() -> dict:
# On derive donc `<groupe>_hostname` de l'exposition declaree, quand elle est UNIQUE
# (deux expositions ne designent aucun nom canonique : on laisse le role choisir).
# `setdefault` cote hote : une valeur posee a la main dans les group_vars gagne encore.
hostname_par_groupe: dict = {}
# PAR HOTE *ET* PAR GROUPE, PAS PAR GROUPE SEUL (2026-09-15).
#
# Un ROLE peut servir DEUX applications sur DEUX machines. `serveur_oauth2_proxy` en
# est l'exemple : une instance devant la vigie sur le noeud de supervision, une autre
# devant la console sur le runner. Deux passerelles, deux clients Keycloak, deux noms.
#
# Indexee par groupe seul, la table gardait le DERNIER nom rencontre et le donnait a
# TOUS les hotes du groupe. Mesure du 2026-09-15 sur Chezlepro :
#
# mon-01 serveur_oauth2_proxy_hostname = console.chezlepro.internal
# ops-01 serveur_oauth2_proxy_hostname = console.chezlepro.internal
#
# La passerelle de la VIGIE se croyait donc la console : son URL de retour OIDC aurait
# vise l'autre machine, et le SSO de la supervision serait tombe. Le defaut du
# 2026-09-10 — « le service porte le nom que son role devine » — reapparaissait par
# une autre porte, et P67 ne le voyait pas puisqu'elle comparait a la valeur qu'elle
# derivait elle-meme de la meme facon.
#
# Une application nomme un COUPLE (machine, role). C'est la clef juste.
hostname_par_hote_groupe: dict = {}
for app in apps.values():
exp = [str(f).strip() for f in (app.get("expose") or []) if str(f).strip()]
groupe = str(app.get("groupe") or "")
if groupe and len(exp) == 1:
hostname_par_groupe[groupe] = exp[0]
groupe, hote = str(app.get("groupe") or ""), str(app.get("hote") or "")
if groupe and hote and len(exp) == 1:
hostname_par_hote_groupe[(hote, groupe)] = exp[0]
# La patte de la frontiere que les tenants traversent — calculee une fois.
_temps_frontiere: list = []
@ -366,9 +385,10 @@ def generer() -> dict:
hostvars.setdefault("chrony_serveurs", _temps_frontiere)
# Nom public de chaque service porte par cet hote, tel que le plan l'expose.
for groupe_expose in sorted(groupes & set(hostname_par_groupe)):
hostvars.setdefault(f"{groupe_expose}_hostname",
hostname_par_groupe[groupe_expose])
for groupe_expose in sorted(groupes):
fqdn = hostname_par_hote_groupe.get((nom, groupe_expose))
if fqdn:
hostvars.setdefault(f"{groupe_expose}_hostname", fqdn)
# Le resolveur de l'ecosysteme doit savoir a qui demander la zone de l'hebergeur.
if "serveur_resolveur" in groupes and zones_deleguees:
hostvars["serveur_resolveur_zones_deleguees"] = zones_deleguees

View file

@ -3507,14 +3507,15 @@ def preuve_nom_public_vient_du_plan() -> tuple[bool, str]:
verifies = 0
for etiquette, attendu, rendu in lots:
enfants, hv = rendu["enfants"], rendu["hv"]
for groupe, fqdn in sorted(attendu.items()):
for hote in sorted((enfants.get(groupe) or {}).get("hosts") or {}):
vu = str((hv.get(hote) or {}).get(f"{groupe}_hostname") or "")
verifies += 1
if vu != fqdn:
fautes.append(f"[{etiquette}] {hote} porte {groupe} sans "
f"`{groupe}_hostname` = `{fqdn}` "
f"(inventaire : {vu or 'absent'})")
for (hote, groupe), fqdn in sorted(attendu.items()):
if hote not in ((enfants.get(groupe) or {}).get("hosts") or {}):
continue # le plan declare l'app ailleurs que la ou le groupe est pose
vu = str((hv.get(hote) or {}).get(f"{groupe}_hostname") or "")
verifies += 1
if vu != fqdn:
fautes.append(f"[{etiquette}] {hote} porte {groupe} sans "
f"`{groupe}_hostname` = `{fqdn}` "
f"(inventaire : {vu or 'absent'})")
if fautes:
return False, ("Un service ne porte pas le nom que le plan lui donne — ses URL de "
"retour viseront l'ancien :\n - " + "\n - ".join(fautes[:6]))
@ -3526,16 +3527,24 @@ def preuve_nom_public_vient_du_plan() -> tuple[bool, str]:
def _noms_exposes(f_app) -> dict:
"""Groupe -> le nom unique que le plan lui donne. Deux noms : aucun canonique."""
"""(machine, groupe) -> le nom unique que le plan lui donne.
PAR HOTE, ET PAS PAR GROUPE SEUL (2026-09-15). Cette preuve indexait par groupe, comme
la derivation qu'elle garde — elle validait donc la meme erreur des deux cotes. Sur
Chezlepro, `serveur_oauth2_proxy` sert la vigie sur un hote et la console sur un
autre : les deux ont recu le DERNIER nom, et la preuve a rendu vert.
Une garde qui reproduit le raisonnement qu'elle verifie ne verifie rien.
"""
apps = (yaml.safe_load(f_app.read_text(encoding="utf-8")) or {}).get("applications") or {}
attendu: dict[str, str] = {}
attendu: dict[tuple[str, str], str] = {}
for a in apps.values():
if not isinstance(a, dict):
continue
exp = [str(f).strip() for f in (a.get("expose") or []) if str(f).strip()]
g = str(a.get("groupe") or "")
if g and len(exp) == 1:
attendu[g] = exp[0]
g, h = str(a.get("groupe") or ""), str(a.get("hote") or "")
if g and h and len(exp) == 1:
attendu[(h, g)] = exp[0]
return attendu

View file

@ -519,12 +519,16 @@ def inventaire() -> dict:
# UNE SEULE EXPOSITION, sinon rien : deux noms ne designent aucun canonique, et le
# role garde alors la main. `setdefault` cote hote : une valeur ecrite a la main dans
# les variables d'une machine gagne encore.
_hostname_par_groupe: dict = {}
# PAR HOTE *ET* PAR GROUPE (2026-09-15) — un role peut servir deux applications sur
# deux machines, et la table indexee par groupe seul donnait le DERNIER nom a tous.
# Voir `instancier.py`, ou le meme defaut a fait croire a la passerelle de la vigie
# qu'elle etait la console.
_hostname_par_hote_groupe: dict = {}
for _app in applications.values():
_exp = [str(f).strip() for f in (_app.get("expose") or []) if str(f).strip()]
_g = str(_app.get("groupe") or "")
if _g and len(_exp) == 1:
_hostname_par_groupe[_g] = _exp[0]
_g, _h = str(_app.get("groupe") or ""), str(_app.get("hote") or "")
if _g and _h and len(_exp) == 1:
_hostname_par_hote_groupe[(_h, _g)] = _exp[0]
hostvars: dict[str, dict] = {}
groupes: dict[str, list[str]] = {}
@ -759,9 +763,10 @@ def inventaire() -> dict:
**(srv.get("variables") or {}),
}
# Nom public de chaque service porte par cet hote, tel que le plan l'expose.
for _groupe_expose in sorted(_groupes_de(nom) & set(_hostname_par_groupe)):
hostvars[nom].setdefault(f"{_groupe_expose}_hostname",
_hostname_par_groupe[_groupe_expose])
for _groupe_expose in sorted(_groupes_de(nom)):
_fqdn = _hostname_par_hote_groupe.get((nom, _groupe_expose))
if _fqdn:
hostvars[nom].setdefault(f"{_groupe_expose}_hostname", _fqdn)
for _socle in GROUPE_SOCLE:
groupes.setdefault(_socle, []).append(nom)
if "serveur_backup_site" in _groupes_de(nom):