TLS des bases : le consommateur suit son serveur, il ne le devine plus

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-14 18:42:21 -04:00
parent d4bc3e48b7
commit c308c9c5b0
9 changed files with 206 additions and 7 deletions

View file

@ -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.**

View file

@ -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 »*.

View file

@ -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

View file

@ -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.

View file

@ -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 }}

View file

@ -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) -------------------------------------------

View file

@ -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) ---

View file

@ -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) ---------------------------------------

View file

@ -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},
]