diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c664d8..3a7a7ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.`, 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 diff --git a/docs/audit/preuve-2026-09-15.md b/docs/audit/preuve-2026-09-15.md index 9b8c3f5..fd46fc1 100644 --- a/docs/audit/preuve-2026-09-15.md +++ b/docs/audit/preuve-2026-09-15.md @@ -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. | diff --git a/exemples/vault.exemple.yml b/exemples/vault.exemple.yml index 1ef9206..989452b 100644 --- a/exemples/vault.exemple.yml +++ b/exemples/vault.exemple.yml @@ -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`. # diff --git a/roles/serveur_ops/defaults/main.yml b/roles/serveur_ops/defaults/main.yml index efc3724..7fe8c56 100644 --- a/roles/serveur_ops/defaults/main.yml +++ b/roles/serveur_ops/defaults/main.yml @@ -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: diff --git a/roles/serveur_ops/templates/nginx-gui.conf.j2 b/roles/serveur_ops/templates/nginx-gui.conf.j2 index 2417844..58343e9 100644 --- a/roles/serveur_ops/templates/nginx-gui.conf.j2 +++ b/roles/serveur_ops/templates/nginx-gui.conf.j2 @@ -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 diff --git a/scripts/instancier.py b/scripts/instancier.py index 4c17a05..5193dcc 100644 --- a/scripts/instancier.py +++ b/scripts/instancier.py @@ -211,12 +211,31 @@ def generer() -> dict: # On derive donc `_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 diff --git a/scripts/prouver.py b/scripts/prouver.py index b643839..b240813 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -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 diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index 4da52c9..9ab5542 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -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):