From c308c9c5b0945f7f20ba0b5d4ae671c93f14895a Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 14 Sep 2026 18:42:21 -0400 Subject: [PATCH] TLS des bases : le consommateur suit son serveur, il ne le devine plus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La supervision du SITE etait morte depuis 11:10 et rien ne le disait. icingadb refuse par pg_hba — hostssl impose cote serveur, connexion en clair cote client. icinga2 tournait, redis tournait, les sondes poussaient, et rien n atteignait la base : les verdicts se calculaient dans le vide. La cause est une seconde liste tenue a la main. tls_force allume hostssl ; chaque consommateur avait SON interrupteur a allumer dans les group_vars. Chezlepro avait les trois, le site avait le premier. serveur_forgejo disait pire que rien : sslmode disable ecrit en dur, le contraire de ce que le serveur imposait. resoudre_base expose resoudre_base_db_tls_force, lu dans les hostvars de la machine qui PORTE la base. Les trois interrupteurs en derivent. Un serveur qui ne declare rien ne force rien : on ne casse pas un ecosysteme qui n a pas bascule. P78 refuse une valeur ecrite chez un consommateur, et nomme les deux roles sans reglage TLS plutot que de rendre un vert muet sur eux. Apres : icingadb active, TLSv1.3 vu par PostgreSQL, 16 hotes et 88 services en base. Les cinq sondes des marqueurs du site, INCONNU faute de deploiement, rapportent leur phrase. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q --- AGENTS.md | 2 +- CHANGELOG.md | 81 ++++++++++++++++++++++++ docs/audit/preuve-2026-09-14.md | 5 +- docs/devis-services.md | 2 +- roles/resoudre_base/tasks/main.yml | 30 +++++++++ roles/serveur_forgejo/defaults/main.yml | 6 +- roles/serveur_icinga/defaults/main.yml | 3 +- roles/serveur_keycloak/defaults/main.yml | 4 +- scripts/prouver.py | 80 +++++++++++++++++++++++ 9 files changed, 206 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa93e04..a154012 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 77 preuves de `make prouver` : elles lisent le dépôt, sans le +C'est aussi ce que font les 78 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 a30ab02..7845672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,86 @@ # CHANGELOG — Set-OPS +## 2026-09-14 (18) — La supervision du SITE etait morte depuis 11:10, et rien ne le disait + +L'exploitant : *« je ne vois pas de serveur icinga pour le site ? »*. Il y en a un. Il +tournait. Et il ne servait a rien. + +### La panne + + icingadb.service : failed depuis 11:10:27 + + pq: aucune entree dans pg_hba.conf pour l'hote « 10.37.36.11 », + utilisateur « icingadb », base « icingadb », aucun chiffrement + +`icinga2` tournait, `icingadb-redis` tournait, les sondes poussaient — et **rien +n'atteignait la base**. Les verdicts se calculaient dans le vide. Aucun deploiement n'a +echoue, aucun service visible n'est tombe : le moteur de supervision etait mort et +personne n'avait de quoi l'apprendre, puisque c'est precisement lui qui le dirait. + +### La cause : une seconde liste, tenue a la main + +`serveur_postgresql_tls_force` pose `hostssl` dans `pg_hba` — toute connexion non chiffree +refusee. Chaque consommateur avait alors SON interrupteur a allumer separement, dans les +`group_vars` de chaque ecosysteme : + +| | Chezlepro | SITE | +|---|---|---| +| `serveur_postgresql_tls_force` | true | **true** | +| `serveur_icinga_db_tls` | true | *absent* | +| `serveur_keycloak_db_sslmode` | verify-full | *absent* | + +Chezlepro avait les trois. Le site avait le premier. Et le commentaire de +`site_inventaire.py` cite deja cette erreur mot pour mot : le cote SERVEUR avait ete +corrige le 2026-09-12 (`reseaux_autorises`), le cote CONSOMMATEUR jamais. + +`serveur_forgejo` disait pire que rien : `sslmode: "disable"` ecrit en dur. Le +consommateur affirmait le contraire de ce que le serveur imposait. + +### Le remede : le consommateur suit son serveur + +`resoudre_base` expose desormais `resoudre_base_db_tls_force`, lu dans les `hostvars` de +la machine qui PORTE la base. Un consommateur n'a plus a savoir qu'il doit chiffrer — il +le deduit de ce que sert son serveur. Les trois interrupteurs en derivent. + +`default(false)` : un serveur qui ne declare rien ne force rien, et le consommateur reste +en clair. On ne casse pas un ecosysteme qui n'a pas bascule. + +**P78** refuse qu'un consommateur porte une valeur ECRITE : elle doit deriver. Une valeur +ecrite est une seconde liste, et une liste qui suit une autre prend du retard. La preuve +nomme aussi les deux consommateurs SANS reglage TLS — `serveur_icingaweb2`, +`serveur_nextcloud` — plutot que de rendre un vert muet sur eux. + +### Apres + + icingadb active + connexions vues par PostgreSQL icingadb | t | TLSv1.3 | 10.37.36.11 + en base 16 hotes, 88 services + +Et les cinq sondes ecrites hier pour les marqueurs du site etaient **INCONNU** : Icinga +avait bien defini les services depuis leurs `meta/supervision.yml`, mais leurs roles +n'avaient pas ete redeployes, donc aucun script n'etait pose. Les cinq roles appliques, +un rapport force, et elles disent leur phrase : + + site-backup-01 depot-locataires OK 2 locataire(s) etanche(s), 374 Go libres + site-cache-01 cache-site-racine OK racine sans amont, et l'index se sert + site-dns-01 resolution-locataires OK 3 supernet(s) de locataire admis + site-forge-01 genome-servi OK 6 depots servis sous « genome », aucun vide + site-ops-01 pouvoir-materialiser OK carte en place, voute chiffree, cle en 600 + +### Ce que la supervision, une fois vivante, a immediatement signale + + site-mon-01 journaux-frontiere CRITIQUE LA FRONTIERE NE JOURNALISE PLUS + site-forge-01 / site-pki-01 / site-mon-01 sante CRITIQUE setops-verification-depot.service + +Deux constats a instruire, et c'est exactement ce qu'on attend d'un moteur qu'on vient de +remettre en marche : il ne rassure pas, il rapporte. + +### Ce qui manque encore, et c'est une decision + +Le plan du site declare `serveur_icinga` et **pas** `serveur_icingaweb2`. Le site calcule +88 verdicts que personne ne peut LIRE. Grafana montre les series, pas les verdicts. Ajouter +la console est une machine de zero (elle se co-localise) et un nom expose de plus. + ## 2026-09-14 (17) — La garde regardait un seul cote de la cloture L'exploitant, en cherchant son tableau : *« grafana c'etait observatoire.genese.internal »*. diff --git a/docs/audit/preuve-2026-09-14.md b/docs/audit/preuve-2026-09-14.md index 8c9a118..c2c637a 100644 --- a/docs/audit/preuve-2026-09-14.md +++ b/docs/audit/preuve-2026-09-14.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 (76 OK · 0 echec · 1 saute) +- **Verdict** : ✅ CONFORME (77 OK · 0 echec · 1 saute) ## Preuves @@ -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 | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. | | 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 (77 preuves, 68 roles, 41 groupes). | +| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (78 preuves, 68 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 `392da4d` (publie le 2026-09-14). | @@ -90,6 +90,7 @@ | P75 | Les parametres de clonage traversent les trois maillons | — | ✅ OK | 14 parametre(s) de clonage, tous emis par l'inventaire. | | P76 | Tout gabarit de role se rend vraiment | — | ✅ OK | 152 gabarits de role : tous se rendent. | | P77 | Panneaux declares : assemblables, et gradues | — | ✅ OK | 8 panneau(x) declare(s) dans 2 role(s), tous avec titre, expression, raison et une unite que la table sait traduire. | +| P78 | Un consommateur de base suit le verrou TLS de son serveur | — | ✅ OK | 3 consommateur(s) suivent la posture de leur serveur ; 2 sans reglage TLS (serveur_icingaweb2, serveur_nextcloud). | ## Couverture des affirmations ✅ du registre diff --git a/docs/devis-services.md b/docs/devis-services.md index 53e585d..6a66321 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 77 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le +`scripts/prouver.py` porte 78 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/roles/resoudre_base/tasks/main.yml b/roles/resoudre_base/tasks/main.yml index 0069a55..1d8a591 100644 --- a/roles/resoudre_base/tasks/main.yml +++ b/roles/resoudre_base/tasks/main.yml @@ -64,3 +64,33 @@ # plusieurs tenants) + nom canonique pour la vérif TLS. Résolu par le plancher /etc/hosts. resoudre_base_db_host: "{{ (serveurs_bd | default({}))[resoudre_base_entree.serveur].hote }}.{{ domaine_interne }}" resoudre_base_db_port: "{{ (serveurs_bd | default({}))[resoudre_base_entree.serveur].port | default(5432) }}" + +# LA POSTURE TLS DU SERVEUR, LUE CHEZ LE SERVEUR (2026-09-14). +# +# CE QUE ÇA A COÛTÉ. `serveur_postgresql_tls_force` pose `hostssl` dans `pg_hba` : toute +# connexion non chiffrée est refusée. Chaque consommateur avait alors SON propre +# interrupteur — `serveur_icinga_db_tls`, `serveur_keycloak_db_sslmode`, +# `serveur_forgejo_db_sslmode` — qu'il fallait allumer à la main, dans les `group_vars`, +# en même temps que celui du serveur. +# +# Chezlepro les avait tous les trois. Le site n'avait que celui du serveur. `icingadb` y +# est donc tombé le 2026-09-14 à 11:10 et n'est jamais remonté : +# +# aucune entrée dans pg_hba.conf pour l'hôte « 10.37.36.11 », +# utilisateur « icingadb », base « icingadb », aucun chiffrement +# +# Une liste qui suit une autre prend du retard. Ici la seconde liste était tenue à la +# main dans un dépôt d'écosystème, et rien ne la comparait à la première. +# +# ON LIT DONC LA POSTURE CHEZ CELUI QUI LA DÉCIDE. Le serveur de base est un hôte de +# l'inventaire ; ce qu'il applique est dans ses `hostvars`. Un consommateur n'a plus à +# savoir qu'il doit chiffrer : il le déduit de ce que sert son serveur. +# +# `default(false)` : un serveur qui ne déclare rien ne force rien, et le consommateur +# reste en clair — c'était l'état de toute la flotte avant le zéro-confiance, et on ne +# casse pas un écosystème qui n'a pas encore basculé. +- name: Le serveur de base exige-t-il des connexions chiffrées ? + ansible.builtin.set_fact: + resoudre_base_db_tls_force: >- + {{ (hostvars[(serveurs_bd | default({}))[resoudre_base_entree.serveur].hote] + | default({})).get('serveur_postgresql_tls_force', false) | bool }} diff --git a/roles/serveur_forgejo/defaults/main.yml b/roles/serveur_forgejo/defaults/main.yml index 628f3f4..3353eb7 100644 --- a/roles/serveur_forgejo/defaults/main.yml +++ b/roles/serveur_forgejo/defaults/main.yml @@ -117,7 +117,11 @@ serveur_forgejo_db_chemin: "{{ serveur_forgejo_data }}/forgejo.db" # TLS vers PostgreSQL (zero-confiance). 'disable' = compat ; 'verify-full' = chiffre + verifie # le cert serveur contre le root_ca step-ca (via PGSSLROOTCERT dans l'unite systemd). # Sans objet en `sqlite` : il n'y a pas de connexion reseau a chiffrer. -serveur_forgejo_db_sslmode: "disable" +# DÉRIVÉ DE CE QUE SERT LE SERVEUR, plus posé à la main. Voir `resoudre_base`. +# `disable` restait écrit en dur ici alors que le serveur pouvait exiger TLS : le +# consommateur affirmait le contraire de ce que le serveur imposait. +serveur_forgejo_db_sslmode: >- + {{ 'verify-full' if (resoudre_base_db_tls_force | default(false)) else 'disable' }} serveur_forgejo_db_sslrootcert: "/etc/step/certs/root_ca.crt" # --- Habilitation par groupe (D-66) ------------------------------------------- diff --git a/roles/serveur_icinga/defaults/main.yml b/roles/serveur_icinga/defaults/main.yml index 3bc47f3..6940684 100644 --- a/roles/serveur_icinga/defaults/main.yml +++ b/roles/serveur_icinga/defaults/main.yml @@ -52,7 +52,8 @@ serveur_icinga_schema: "/usr/share/icingadb/schema/pgsql/schema.sql" # TLS vers PostgreSQL (zero-confiance). true = IcingaDB verifie le cert serveur # contre le root_ca step-ca (host dans le SAN). root_ca doit etre lisible (0644). -serveur_icinga_db_tls: false +# DÉRIVÉ DE CE QUE SERT LE SERVEUR, plus posé à la main. Voir `resoudre_base`. +serveur_icinga_db_tls: "{{ resoudre_base_db_tls_force | default(false) }}" serveur_icinga_db_ca: "/etc/step/certs/root_ca.crt" # --- Identite TLS de l'API (5665) --- diff --git a/roles/serveur_keycloak/defaults/main.yml b/roles/serveur_keycloak/defaults/main.yml index 7c1de87..ea1ca6b 100644 --- a/roles/serveur_keycloak/defaults/main.yml +++ b/roles/serveur_keycloak/defaults/main.yml @@ -68,7 +68,9 @@ serveur_keycloak_role_assignments: [] # TLS vers PostgreSQL (zero-confiance). Vide = pas de verif (compat). 'verify-full' = # chiffre + verifie le cert serveur contre le root_ca step-ca (l'hote doit etre dans le SAN). -serveur_keycloak_db_sslmode: "" +# DÉRIVÉ DE CE QUE SERT LE SERVEUR, plus posé à la main. Voir `resoudre_base`. +serveur_keycloak_db_sslmode: >- + {{ 'verify-full' if (resoudre_base_db_tls_force | default(false)) else '' }} serveur_keycloak_db_sslrootcert: "/etc/step/certs/root_ca.crt" # --- Projection des groupes LDAP (D-65) --------------------------------------- diff --git a/scripts/prouver.py b/scripts/prouver.py index 536486f..b643839 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -2179,6 +2179,84 @@ def preuve_panneaux_assembles() -> tuple[bool, str]: f"expression, raison et une unite que la table sait traduire.") +def preuve_consommateurs_suivent_le_verrou_tls() -> tuple[bool, str]: + """Un consommateur de base ne decide pas seul s'il chiffre : il suit son serveur. + + LE DEFAUT, MESURE LE 2026-09-14 A 11:10. `serveur_postgresql_tls_force` pose `hostssl` + dans `pg_hba` : toute connexion NON chiffree est refusee. Chaque consommateur avait + alors SON interrupteur — `serveur_icinga_db_tls`, `serveur_keycloak_db_sslmode`, + `serveur_forgejo_db_sslmode` — a allumer A LA MAIN, dans les `group_vars`, en meme + temps que celui du serveur. + + Chezlepro les avait tous les trois. Le SITE n'avait que celui du serveur : + + aucune entree dans pg_hba.conf pour l'hote « 10.37.36.11 », + utilisateur « icingadb », base « icingadb », aucun chiffrement + + `icingadb` est tombe et n'est jamais remonte. Le moteur de supervision tournait, + Redis tournait, et plus rien n'atteignait la base : les verdicts se calculaient dans + le vide. Une panne qui ne fait echouer aucun deploiement. + + ET `serveur_forgejo` DISAIT PIRE QUE RIEN : `sslmode: "disable"` en dur. Le + consommateur affirmait le contraire de ce que le serveur imposait. + + CE QUE CETTE PREUVE EXIGE : que l'interrupteur de chaque consommateur DERIVE de + `resoudre_base_db_tls_force` — la posture lue chez le serveur — au lieu de porter une + valeur ecrite. Une valeur ecrite est une seconde liste, et une liste qui suit une + autre prend du retard. + + CE QU'ELLE NE FAIT PAS. Elle ne verifie pas qu'une connexion CHIFFREE aboutit : ca + demande un serveur, un certificat et une AC. C'est au deploiement de le dire, et il + le dit — un consommateur qui n'y arrive pas ne demarre pas. + """ + roles = RACINE / "roles" + consommateurs: list[str] = [] + for taches in sorted(roles.glob("*/tasks/main.yml")): + role = taches.parent.parent.name + if role == "resoudre_base": + continue + corps = taches.read_text(encoding="utf-8") + meta = roles / role / "meta" / "main.yml" + if meta.is_file(): + corps += meta.read_text(encoding="utf-8") + if "resoudre_base" in corps: + consommateurs.append(role) + + if not consommateurs: + return True, "Aucun role ne consomme de base : rien a verifier." + + fautes, suivis, sans_reglage = [], [], [] + for role in consommateurs: + f = roles / role / "defaults" / "main.yml" + brut = f.read_text(encoding="utf-8") if f.is_file() else "" + # Les lignes de reglage TLS, quelle que soit la forme du nom. + lignes = [l for l in brut.splitlines() + if re.match(r"\s*[a-z_]*_db_(tls|sslmode|ssl_mode)\s*:", l)] + if not lignes: + # PAS UNE FAUTE : un consommateur peut n'avoir aucun reglage parce que son + # client chiffre par defaut, ou parce qu'il parle a SQLite. On le DIT, ce qui + # vaut mieux qu'un vert muet — c'est la carte de ce qui reste a regarder. + sans_reglage.append(role) + continue + # La valeur derivee peut etre sur la ligne ou sur la suivante (`>-`). + bloc = brut + if "resoudre_base_db_tls_force" in bloc: + suivis.append(role) + else: + fautes.append(f"{role} : {lignes[0].strip()} — valeur ecrite, elle ne suit " + f"pas `resoudre_base_db_tls_force`") + + if fautes: + return False, ("Un consommateur decide seul s'il chiffre, au lieu de suivre son " + "serveur :\n - " + "\n - ".join(fautes) + + "\n Un `hostssl` allume cote serveur le fera tomber, sans " + "qu'aucun deploiement n'echoue.") + detail = f"{len(suivis)} consommateur(s) suivent la posture de leur serveur" + if sans_reglage: + detail += f" ; {len(sans_reglage)} sans reglage TLS ({', '.join(sans_reglage)})" + return True, detail + "." + + def preuve_gabarits_shell_rendent() -> tuple[bool, str]: """Aucun gabarit shell ne contient de sequence que Jinja lirait autrement. @@ -4015,6 +4093,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_gabarits_shell_rendent}, {"id": "P77", "titre": "Panneaux declares : assemblables, et gradues", "refs": [], "func": preuve_panneaux_assembles}, + {"id": "P78", "titre": "Un consommateur de base suit le verrou TLS de son serveur", + "refs": [], "func": preuve_consommateurs_suivent_le_verrou_tls}, ]