diff --git a/AGENTS.md b/AGENTS.md index 198d276..484152e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente ## Écrire, puis relire (D-68) `--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**. -C'est aussi ce que font les 65 preuves de `make prouver` : elles lisent le dépôt, sans le +C'est aussi ce que font les 66 preuves de `make prouver` : elles lisent le dépôt, sans le moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le dépôt annonce.** diff --git a/CHANGELOG.md b/CHANGELOG.md index beeb1bf..a04b11f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # CHANGELOG — Set-OPS +## 2026-09-10 (17) — La console d'observabilite s'appelle pareil des deux cotes + +Deux ecosystemes, deux noms pour le meme service : `grafana.chezlepro.internal` chez le +locataire, `tableaux.genese.internal` au site. Le second etait de notre fait, pose le jour +meme en montant la pile d'observabilite du site. + + grafana.chezlepro.internal -> observatoire.chezlepro.internal + tableaux.genese.internal -> observatoire.genese.internal + +### Pourquoi pas `grafana` + +Un nom de produit dans une URL se grave ailleurs qu'a l'ecran : dans les SAN du certificat, +dans les URI de redirection du SSO, dans les signets de l'exploitant. Remplacer Grafana +obligerait alors a renommer le service — donc a refaire le certificat et le client Keycloak +pour une raison qui n'a rien a voir avec eux. + +Le plan du locataire nommait deja quatre services par leur FONCTION (`auth`, `cloud`, +`bureau`, `forge`) et deux par leur PRODUIT (`grafana`, `icinga`). Celui-ci rejoint le +registre majoritaire. `icinga` reste, pour l'instant : c'est un autre geste. + +### Ce que le renommage a revele + +Les URI de redirection des clients Keycloak **repetent a la main** les FQDN que +`plan/applications.yml` declare dans `expose:`. Rien ne les relie. Renommer l'exposition +regenere `hosts.yml`, le certificat et la zone DNS — et laisse le client OIDC viser l'ancien +nom. Keycloak ne proteste pas : c'est l'utilisateur qui le decouvre au retour du SSO. + +**P66** (`preuve_clients_oidc_vises_sur_une_exposition`) : chaque `redirect_uris` et chaque +`web_origins` doit viser un FQDN qu'une application expose. Ecrite EN MEME TEMPS que le +renommage, parce que c'est le seul moment ou l'on sait encore que les deux listes existent. + + P66 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose + +### Une erreur de lecture, au passage + +En cherchant pourquoi `tableaux.genese.internal` ne resolvait pas depuis le poste, on a +conclu que le nom n'etait pas dans la zone. Il y etait — PowerDNS le publiait correctement, +comme `sauvegarde.genese.internal`. Ce qui ne les connaissait pas, c'est le RESOLVEUR DU +POSTE (`192.168.10.10`), qui porte trois entrees faites a la main et aucune delegation de +`genese.internal`. L'index consulte n'etait pas la source. + ## 2026-09-10 (16) — La frontiere journalise a nouveau, et une garde veille cette fois Doctrine posee par l'exploitant : *des lors que le site a son Loki, la journalisation de la diff --git a/docs/audit/plan-de-recette.md b/docs/audit/plan-de-recette.md index a9e502b..84c453e 100644 --- a/docs/audit/plan-de-recette.md +++ b/docs/audit/plan-de-recette.md @@ -88,7 +88,7 @@ le côté humain **est** la preuve de l'affirmation « exploitable sans IA » (c | # | Ce qu'on éprouve | Le geste (avec l'attendu) | Type | Preuve auto | |---|---|---|---|---| -| 1 | Vis le SSO | Ouvre https://grafana.chezlepro.internal → « *Se connecter avec Chezlepro* » → testmail. Puis ouvre Forgejo, puis Icinga : tu n'es reconnecté nulle part. | 👁 observe | — | +| 1 | Vis le SSO | Ouvre https://observatoire.chezlepro.internal → « *Se connecter avec Chezlepro* » → testmail. Puis ouvre Forgejo, puis Icinga : tu n'es reconnecté nulle part. | 👁 observe | — | | 2 | Observe le flux | Rouvre Grafana en navigation privée, ouvre les outils dév (F12 → Réseau) : repère la redirection vers Keycloak, puis le retour avec un code=. C'est ① en action. | 👁 observe | — | | 3 | Interroge l'annuaire (la source de vérité) | Sur un nœud avec ldap-utils : « commande » Tu vois l'entrée que Keycloak fédère — il ne l'a pas recopiée. | 👁 observe | — | | 4 | Casse & répare (la fédération) | Dans la console admin Keycloak → *User Federation* → désactive le fournisseur LDAP. Reconnecte-toi : échec (l'IdP ne voit plus l'annuaire). Réactive : ça remarche. Tu viens de *sentir* la dépendance requise entre l'IdP et l'annuaire. | 🔨 casse-répare | — | @@ -184,7 +184,7 @@ le côté humain **est** la preuve de l'affirmation « exploitable sans IA » (c |---|---|---|---|---| | 1 | Interroge les métriques | (sur obs-01) — combien de nœuds scrapés, tous UP ? « commande » | 👁 observe | — | | 2 | Vois les journaux | : curl -s http://localhost:3100/loki/api/v1/label/host/values → les nœuds qui expédient leurs logs. | 👁 observe | — | -| 3 | Ouvre Grafana | (https://grafana.chezlepro.internal) — métriques *et* logs au même endroit. | 👁 observe | — | +| 3 | Ouvre Grafana | (https://observatoire.chezlepro.internal) — métriques *et* logs au même endroit. | 👁 observe | — | | 4 | Casse & répare | Arrête prometheus-node-exporter sur un nœud : dans Prometheus, sa cible passe up=0 (DOWN). Redémarre : elle repasse UP. Tu *sens* que c'est l'agent qui nourrit le serveur (modèle pull). | 🔨 casse-répare | — | *Source : [Métriques & journaux](../../wiki/M%C3%A9triques-et-journaux.md) · § À toi de jouer.* @@ -205,7 +205,7 @@ le côté humain **est** la preuve de l'affirmation « exploitable sans IA » (c | # | Ce qu'on éprouve | Le geste (avec l'attendu) | Type | Preuve auto | |---|---|---|---|---| | 1 | Route par nom | Deux noms, un seul edge (10.17.16.11) : « commande » Change grafana en forge : même IP, backend différent. C'est le routage par SNI. | 👁 observe | — | -| 2 | Vois la terminaison TLS | Le certificat présenté est celui de l'edge (avec les SAN des exposés) : openssl s_client -connect 10.17.16.11:443 -servername grafana.chezlepro.internal \| openssl x509 -noout -text \| grep -A1 'Subject Alternative'. | 👁 observe | — | +| 2 | Vois la terminaison TLS | Le certificat présenté est celui de l'edge (avec les SAN des exposés) : openssl s_client -connect 10.17.16.11:443 -servername observatoire.chezlepro.internal \| openssl x509 -noout -text \| grep -A1 'Subject Alternative'. | 👁 observe | — | | 3 | Casse & répare | Arrête le backend (ex. systemctl stop grafana-server sur obs-01) et rouvre Grafana : l'edge répond 502 Bad Gateway (le proxy est là, le service non). Redémarre : ça remarche. Tu distingues le proxy de ce qu'il sert. | 🔨 casse-répare | — | *Source : [Reverse-proxy & TLS](../../wiki/Reverse-proxy-et-TLS.md) · § À toi de jouer.* diff --git a/docs/audit/preuve-2026-09-10.md b/docs/audit/preuve-2026-09-10.md index 7ababe9..02cd292 100644 --- a/docs/audit/preuve-2026-09-10.md +++ b/docs/audit/preuve-2026-09-10.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (65 OK · 0 echec · 0 saute) +- **Verdict** : ✅ CONFORME (65 OK · 0 echec · 1 saute) ## Preuves @@ -28,7 +28,7 @@ | P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. | | P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. | | 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 | ✅ OK | 14 hotes, 34 groupes (inventaire dechiffre et parse). | +| 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 : 23 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. | @@ -69,7 +69,7 @@ | P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. | | P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 14 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 14. | | P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. | -| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (65 preuves, 67 roles, 41 groupes). | +| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (66 preuves, 67 roles, 41 groupes). | | P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. | | P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). | | P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `2d9dc86` (publie le 2026-09-09). | @@ -78,6 +78,7 @@ | 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 | 24 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 | 4 client(s) OIDC, toutes leurs URI visent un FQDN que le plan expose (6 exposition(s)). | ## Couverture des affirmations ✅ du registre diff --git a/docs/devis-services.md b/docs/devis-services.md index af2b61d..3b779fa 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met ## Le trou qu'il comble -`scripts/prouver.py` porte 65 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le +`scripts/prouver.py` porte 66 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est cohérent **avec lui-même** — que les handlers existent, que les intrants ont un propriétaire, que rien n'est codé en dur. diff --git a/scripts/prouver.py b/scripts/prouver.py index fb2fd5b..4a9a64d 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -3103,6 +3103,60 @@ def preuve_depots_tiers_par_le_cache() -> tuple[bool, str]: f"vise en https:// ecrit en dur.") +def preuve_clients_oidc_vises_sur_une_exposition() -> tuple[bool, str]: + """Chaque client OIDC pointe un nom que le plan EXPOSE vraiment. + + UNE LISTE QUI SUIT UNE AUTRE PREND DU RETARD (2026-09-10). Les URI de redirection des + clients Keycloak repetent, a la main, les FQDN que `plan/applications.yml` declare dans + `expose:`. Rien ne les relie : renommer une exposition regenere `hosts.yml`, le + certificat et la zone DNS — et laisse le client OIDC viser l'ancien nom. + + Le defaut ne se voit pas au deploiement. Keycloak accepte sans broncher une URI de + redirection vers un nom qui ne resout plus ; c'est l'utilisateur qui le decouvre, au + retour du SSO, devant un `invalid_redirect_uri` ou une page morte. + + Cette garde est ecrite EN MEME TEMPS que le premier renommage (`grafana` -> + `observatoire`), parce que c'est le seul moment ou l'on sait encore que les deux listes + existent. + """ + instance = instance_courante() + f_app = instance / "plan" / "applications.yml" + if not f_app.is_file(): + return True, "Aucun plan d'applications : pas de client OIDC a verifier." + apps = (yaml.safe_load(f_app.read_text(encoding="utf-8")) or {}).get("applications") or {} + exposes = {str(f).strip().lower() + for a in apps.values() if isinstance(a, dict) + for f in (a.get("expose") or [])} + + from urllib.parse import urlsplit + fautifs: list[str] = [] + clients = 0 + for gv in sorted((instance / "inventories").glob("*/group_vars/*.yml")): + data = yaml.safe_load(gv.read_text(encoding="utf-8")) or {} + if not isinstance(data, dict): + continue + for cl in (data.get("serveur_keycloak_clients") or []): + if not isinstance(cl, dict): + continue + clients += 1 + nom = str(cl.get("clientId") or "?") + for champ in ("redirect_uris", "web_origins"): + for uri in (cl.get(champ) or []): + hote = urlsplit(str(uri)).hostname or "" + if hote in ("", "*"): + continue + if hote.lower() not in exposes: + fautifs.append(f"{gv.name}: client `{nom}` {champ} vise `{hote}`, " + f"qu'aucune application n'expose") + if not clients: + return True, "Aucun client OIDC declare dans cette instance." + if fautifs: + return False, ("Client OIDC vise un nom non expose — le SSO renverra l'utilisateur " + "dans le vide :\n - " + "\n - ".join(sorted(set(fautifs)))) + return True, (f"{clients} client(s) OIDC, toutes leurs URI visent un FQDN que le plan " + f"expose ({len(exposes)} exposition(s)).") + + PREUVES: list[dict] = [ {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], "cmds": [["ansible-lint", "-q"]]}, @@ -3253,6 +3307,8 @@ PREUVES: list[dict] = [ "cmds": [[sys.executable, "scripts/verifier_ports.py"]]}, {"id": "P65", "titre": "Depots tiers : demandes au cache, jamais en HTTPS direct", "refs": [], "func": preuve_depots_tiers_par_le_cache}, + {"id": "P66", "titre": "Clients OIDC : chaque URI vise un nom que le plan expose", + "refs": [], "func": preuve_clients_oidc_vises_sur_une_exposition}, ] diff --git a/wiki/Identité-et-SSO.md b/wiki/Identité-et-SSO.md index 8d93854..b93c95c 100644 --- a/wiki/Identité-et-SSO.md +++ b/wiki/Identité-et-SSO.md @@ -84,7 +84,7 @@ Change les produits, le **schéma reste**. C'est ça, un savoir générique. > Prérequis : accès au lab (VPN), `/etc/hosts` pointant les services sur l'edge. -1. **Vis le SSO.** Ouvre `https://grafana.chezlepro.internal` → « *Se connecter avec +1. **Vis le SSO.** Ouvre `https://observatoire.chezlepro.internal` → « *Se connecter avec Chezlepro* » → `testmail`. Puis ouvre Forgejo, puis Icinga : **tu n'es reconnecté nulle part**. 2. **Observe le flux.** Rouvre Grafana en navigation privée, ouvre les outils dév (F12 → Réseau) : repère la **redirection vers Keycloak**, puis le **retour avec un `code=`**. C'est ① en action. diff --git a/wiki/Métriques-et-journaux.md b/wiki/Métriques-et-journaux.md index a677310..b943e0f 100644 --- a/wiki/Métriques-et-journaux.md +++ b/wiki/Métriques-et-journaux.md @@ -65,7 +65,7 @@ Tu as appris **métriques vs logs, pull vs push, le motif agent** — pas « Pro ``` 2. **Vois les journaux** : `curl -s http://localhost:3100/loki/api/v1/label/host/values` → les nœuds qui expédient leurs logs. -3. **Ouvre Grafana** (`https://grafana.chezlepro.internal`) — métriques *et* logs au même endroit. +3. **Ouvre Grafana** (`https://observatoire.chezlepro.internal`) — métriques *et* logs au même endroit. 4. **Casse & répare.** Arrête `prometheus-node-exporter` sur un nœud : dans Prometheus, sa cible passe **`up=0`** (DOWN). Redémarre : elle repasse UP. Tu *sens* que c'est **l'agent** qui nourrit le serveur (modèle pull). diff --git a/wiki/Reverse-proxy-et-TLS.md b/wiki/Reverse-proxy-et-TLS.md index 71fec56..c877f8d 100644 --- a/wiki/Reverse-proxy-et-TLS.md +++ b/wiki/Reverse-proxy-et-TLS.md @@ -51,12 +51,12 @@ Tu as appris **le reverse-proxy, le routage par nom, la terminaison TLS** — pa 1. **Route par nom.** Deux noms, un seul edge (`10.17.16.11`) : ```bash - curl -sI --resolve grafana.chezlepro.internal:443:10.17.16.11 \ - --cacert /etc/step/certs/root_ca.crt https://grafana.chezlepro.internal/ | head -1 + curl -sI --resolve observatoire.chezlepro.internal:443:10.17.16.11 \ + --cacert /etc/step/certs/root_ca.crt https://observatoire.chezlepro.internal/ | head -1 ``` Change `grafana` en `forge` : même IP, **backend différent**. C'est le routage par SNI. 2. **Vois la terminaison TLS.** Le certificat présenté est celui de l'**edge** (avec les SAN des - exposés) : `openssl s_client -connect 10.17.16.11:443 -servername grafana.chezlepro.internal | openssl x509 -noout -text | grep -A1 'Subject Alternative'`. + exposés) : `openssl s_client -connect 10.17.16.11:443 -servername observatoire.chezlepro.internal | openssl x509 -noout -text | grep -A1 'Subject Alternative'`. 3. **Casse & répare.** Arrête le backend (ex. `systemctl stop grafana-server` sur obs-01) et rouvre Grafana : l'edge répond **502 Bad Gateway** (le proxy est là, le service non). Redémarre : ça remarche. Tu distingues **le proxy** de **ce qu'il sert**.