expositions-etat : le certificat SERVI, pas celui du disque

Renommer une exposition touche cinq choses. Quatre suivent au deploiement ;
la cinquieme est celle que le navigateur regarde. serveur_nginx pose le vhost
et laisse le SAN en arriere — c est client_pki qui reemet, et rien ne le
reclame. Mesure deux fois le 2026-09-10.

Le controle compare trois listes : les expositions du plan, les noms du
certificat servi, et le code que rend le vhost. Dans les deux sens : un nom
reste dans le SAN apres avoir quitte le plan continue d etre authentifie.

Piege trouve en l ecrivant : client_pki met toujours le FQDN et le nom court
de la machine dans le SAN. Les compter comme vestiges faisait crier le
controle a chaque execution — et un controle qui crie toujours ne se lit plus.

Deux formes de terminaison, une seule question : le locataire termine sur un
edge, le site sur la machine elle-meme. Le porteur se derive de l une ou de
l autre.

Ce n est pas une preuve : rien de statique ne peut lire un certificat servi.
Les quatre controles a la demande n etaient nommes nulle part comme famille ;
ils ont maintenant leur section dans les runbooks.

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-11 10:21:19 -04:00
parent 5f5af70a9c
commit 989398f3cf
6 changed files with 315 additions and 4 deletions

View file

@ -1,5 +1,51 @@
# CHANGELOG — Set-OPS
## 2026-09-11 (3) — `make expositions-etat` : le certificat SERVI, pas celui du disque
Renommer une exposition touche cinq choses. Quatre suivent au deploiement ; la cinquieme,
non — et c'est celle que le navigateur regarde.
serveur_powerdns la zone publie le nouveau nom OK
serveur_keycloak le client OIDC accepte le retour OK
le service il fabrique ses URL avec le bon nom OK (P67)
serveur_nginx le vhost repond sur le nouveau nom OK
client_pki le SAN du certificat porte le nom NON il faut le rejouer
Mesure DEUX FOIS le 2026-09-10, sur `grafana -> observatoire` puis `icinga -> vigie`. Le
symptome est trompeur : le site repond, la page s'affiche, et c'est le NAVIGATEUR qui
refuse — avec une erreur de certificat que personne ne relie a un renommage fait la veille.
### Il regarde dans les deux sens
Un nom RESTE dans le SAN apres avoir quitte le plan est un nom que le certificat continue
d'authentifier. C'est exactement ce qu'avait laisse le premier renommage.
Le piege de ce second sens, trouve en l'ecrivant : `client_pki` met TOUJOURS le FQDN et le
nom court de la machine dans le SAN. Les compter comme vestiges faisait crier le controle a
chaque execution — et un controle qui crie toujours ne se lit plus.
### Deux formes de terminaison, une seule question
Le locataire termine son TLS sur un edge ; `sans_exposition` y designe le porteur. Le SITE
n'a pas d'edge — `site-forge-01` ecoute lui-meme sur 443 — et la variable n'y existe donc
pas. Le porteur se derive alors de l'application : son `hote` dit quelle machine la sert.
OPS-Chezlepro 6 expositions toutes servies, certificat conforme, rien en trop
SITE 5 expositions INJOIGNABLE depuis ce poste
Le second resultat n'accuse pas le service, et le controle le dit : les zones du SITE ne
sont pas routees depuis le plan d'administration du locataire. Le mur est la frontiere.
### Pourquoi pas une preuve
`make prouver` est STATIQUE — il lit le depot, zero appel reseau, et c'est ce qui le rend
rejouable partout. Rien de statique ne peut lire un certificat SERVI : il faut ouvrir la
connexion.
Les quatre controles a la demande n'etaient nommes nulle part comme famille —
`routes-fabric-etat` n'apparaissait dans aucune documentation. Ils ont maintenant leur
section (§8 des runbooks). Une garde que personne ne sait lancer ne sert a rien.
## 2026-09-11 (2) — La piste d'audit sort de la machine auditee
`auditd` tournait, onze regles etaient armees, et rien de tout cela ne servait a grand-chose.

View file

@ -1079,6 +1079,15 @@ routes-fabric-etat: ansible-runtime ## Les hyperviseurs routent-ils toutes les z
@# ne ressemblait pas a une route manquante — il ressemblait a un pare-feu.
python3 scripts/routes_fabric_etat.py
.PHONY: expositions-etat
expositions-etat: ## Chaque exposition est-elle servie sous un certificat qui la porte ? — ne corrige rien
@# `serveur_nginx` POSE LE VHOST ET LAISSE LE SAN EN ARRIERE. Mesure deux fois le
@# 2026-09-10, sur `grafana -> observatoire` puis `icinga -> vigie` : la zone publiait
@# le nouveau nom, le vhost repondait, le client OIDC l'acceptait — et le certificat
@# servi portait encore l'ancien. C'est `client_pki` qui reemet, et rien ne le reclame.
@# Ajouter `--site` pour l'ecosysteme du SITE.
python3 scripts/expositions_etat.py $(if $(SITE),--site)
.PHONY: gabarit-etat
gabarit-etat: ## Le gabarit porte-t-il ce que le SITE declare ? — ne corrige rien, regarde
python3 scripts/gabarit_etat.py

View file

@ -43,7 +43,7 @@
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 34 VM placee(s), aucun nom ni VMID en collision. |
| 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 | 59 scripts expliques et atteignables, 116 cibles make documentees, 68 roles avec README. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 60 scripts expliques et atteignables, 117 cibles make documentees, 68 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (137 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 34 revendication(s) de port, aucune collision entre roles co-localises (36 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 44 document(s) declarent leur lecteur (37 genere(s) exempte(s)). |
@ -53,7 +53,7 @@
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 41 role(s) serveur/client tous nommes, 41 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 55 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 56 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 155 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
@ -72,7 +72,7 @@
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (68 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 `53e2f78` (publie le 2026-09-11). |
| 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 `5f5af70` (publie le 2026-09-11). |
| P61 | Schema du plan : il decrit tout ce que les plans contiennent | AFF-033 | ✅ OK | Le schema decrit 47 champ(s) sur 6 registres ; il couvre tout ce que les plans reels contiennent, et la FORME de chaque champ (scalaire / objet / table) corresp |
| P62 | Schema du plan : il decrit tout ce que le MOTEUR accepte | AFF-033 | ✅ OK | Les 4 validateurs n'acceptent aucun champ que le schema ignore (applications:8, bases_donnees:4, domaines_publics:5, serveurs:3 champ(s) lus par validateur). |
| 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 |

View file

@ -1,5 +1,5 @@
---
# Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main.
remote: ssh://git@forge.genese.internal/genome/set-ops-public.wiki.git
source: 53e2f78
source: 5f5af70
date: 2026-09-11

View file

@ -282,3 +282,47 @@ connue et courte. Deux remèdes de fond existent, aucun n'est appliqué :
*Une dépendance qu'on documente sans la mesurer reste une dépendance qu'on découvrira au
mauvais moment.*
## 8. Les contrôles à la demande — ce que `make prouver` ne peut pas voir
`make prouver` est **statique** : il lit le dépôt, zéro appel réseau. C'est ce qui le rend
rejouable partout, par n'importe qui, et présentable comme pièce justificative. Le prix de
cette propriété : il ne peut rien dire de ce qui ne s'observe qu'en ouvrant une connexion.
Quatre contrôles comblent ce creux. Aucun ne corrige quoi que ce soit — ils regardent, et
rendent `0` si tout concorde.
| Contrôle | Ce qu'il compare | Le piège qu'il attrape |
| --- | --- | --- |
| `make expositions-etat` | Expositions du plan ↔ **SAN du certificat servi** ↔ code du vhost | Un renommage déployé partout **sauf** dans le certificat |
| `make gabarit-etat` | Gabarit déclaré ↔ VM modèle réelle | Le modèle a dérivé de ce que le plan promet |
| `make routes-fabric-etat` | Zones déclarées ↔ routes déclarées ↔ routes vivantes | Une route **vivante mais non déclarée** — elle part au redémarrage |
| `make frontiere-plan` | Registre des flux ↔ règles de la frontière | Une règle posée à la main, qu'aucune déclaration ne porte |
Ajouter `SITE=1` à `expositions-etat` pour interroger l'écosystème du SITE plutôt que
l'instance montée.
### Pourquoi `expositions-etat` existe
Renommer une exposition touche cinq choses. Quatre suivent au déploiement ; la cinquième,
non :
```
serveur_powerdns la zone publie le nouveau nom ✓
serveur_keycloak le client OIDC accepte le retour ✓
le service il fabrique ses URL avec le bon nom ✓ (P67)
serveur_nginx le vhost répond sur le nouveau nom ✓
client_pki le SAN du certificat porte le nom ✗ il faut le rejouer
```
Le symptôme est trompeur : le site répond, la page s'affiche, et c'est le **navigateur**
qui refuse — avec une erreur de certificat que personne ne relie à un renommage fait la
veille. Mesuré deux fois le 2026-09-10, sur `grafana → observatoire` puis `icinga → vigie`.
Le contrôle regarde **dans les deux sens**. Un nom resté dans le SAN après avoir quitté le
plan est un nom que le certificat continue d'authentifier : c'est exactement ce qu'avait
laissé le premier renommage, jusqu'au passage de `client_pki`.
> Un `INJOIGNABLE` ne condamne pas le service : il dit que **ce poste** n'a pas pu ouvrir
> la connexion. Les zones du SITE ne sont pas routées depuis le plan d'administration du
> locataire — le mur est la frontière, pas le vhost.

212
scripts/expositions_etat.py Normal file
View file

@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""Chaque exposition du plan est-elle VRAIMENT servie, et par le bon certificat ?
Ne corrige rien.
POURQUOI CE CONTROLE EXISTE (2026-09-11).
Renommer une exposition touche cinq choses, et une seule ne suit pas toute seule. Mesure
deux fois le 2026-09-10, sur `grafana -> observatoire` puis sur `icinga -> vigie` :
serveur_powerdns la zone publie le nouveau nom ✓ au deploiement
serveur_keycloak le client OIDC accepte le retour ✓ au deploiement
le service il fabrique ses URL avec le bon nom ✓ (P67)
serveur_nginx le vhost repond sur le nouveau nom ✓ au deploiement
client_pki le SAN du certificat porte le nom ✗ PAS au deploiement
`serveur_nginx` pose le vhost et laisse le SAN en arriere : c'est `client_pki` qui reemet,
et rien ne le reclame. Le symptome est trompeur — le site repond, la page s'affiche, et
c'est le NAVIGATEUR qui refuse, avec une erreur de certificat que personne ne relie a un
renommage fait la veille.
CE QU'ON COMPARE, ET DANS LES DEUX SENS :
1. les expositions DECLAREES par `plan/applications.yml` (la verite) ;
2. les noms PORTES par le certificat reellement SERVI (pas celui sur le disque) ;
3. le code que le vhost REND pour ce nom.
Le sens inverse compte autant : un nom qui reste dans le SAN apres avoir disparu du plan
est un nom que le certificat continue d'authentifier. C'est exactement ce qu'a laisse le
premier renommage, jusqu'au passage de `client_pki`.
POURQUOI CE N'EST PAS UNE PREUVE DU HARNAIS. `make prouver` est STATIQUE : il lit le
depot, zero appel reseau, et c'est ce qui le rend rejouable partout. Rien de statique ne
peut lire un certificat SERVI — il faut ouvrir la connexion. C'est donc un controle a la
demande, comme `make gabarit-etat` et `make routes-fabric-etat`.
Usage :
python3 scripts/expositions_etat.py # l'instance montee
python3 scripts/expositions_etat.py --site # l'ecosysteme du SITE
"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
RACINE = Path(__file__).resolve().parent.parent
# LE PORT EST UNE CONVENTION, PAS UNE DERIVATION. Le champ `port:` du plan est celui que
# le service ECOUTE en local (Forgejo 3000), pas celui sous lequel il est EXPOSE. Toutes
# les expositions de Set-OPS sont servies en TLS sur 443 ; le jour ou ce ne sera plus vrai,
# c'est ici qu'il faudra le declarer.
PORT = 443
def _san_servi(adresse: str, fqdn: str) -> tuple[list[str], str]:
"""Les noms du certificat que le serveur PRESENTE pour ce SNI. ([], raison) si muet."""
try:
poignee = subprocess.run(
["openssl", "s_client", "-connect", f"{adresse}:{PORT}",
"-servername", fqdn, "-verify_quiet"],
stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=20)
except subprocess.TimeoutExpired:
return [], "silence (aucune reponse TLS)"
if "BEGIN CERTIFICATE" not in poignee.stdout:
premiere = (poignee.stderr or "").strip().splitlines()
return [], (premiere[0][:70] if premiere else "aucun certificat presente")
texte = subprocess.run(
["openssl", "x509", "-noout", "-ext", "subjectAltName"],
input=poignee.stdout, capture_output=True, text=True, timeout=20).stdout
return sorted(set(re.findall(r"DNS:([^\s,]+)", texte))), ""
def _code_http(adresse: str, fqdn: str) -> str:
r = subprocess.run(
["curl", "-sk", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "12",
"--resolve", f"{fqdn}:{PORT}:{adresse}", f"https://{fqdn}/"],
capture_output=True, text=True, timeout=25)
return (r.stdout or "000").strip()
def _expositions_et_porteurs(site: bool) -> tuple[dict[str, tuple[str, str]], str]:
"""{fqdn expose: (adresse qui le sert, nom court du porteur)}. L'adresse vient de `sans_exposition`, pose par
le generateur sur l'hote dont les groupes contiennent l'edge de cette exposition —
la MEME derivation que celle qui alimente le certificat. Interroger une autre source
reviendrait a fabriquer une troisieme liste."""
import yaml
if site:
# L'INVENTAIRE DU SITE EST DYNAMIQUE, PAS STATIQUE : il rend
# `{groupe: {hosts: [noms]}, _meta: {hostvars: {...}}}` — la forme qu'Ansible
# attend d'un script, et non l'arbre `all.children` d'un `hosts.yml`.
import site_inventaire
inv = site_inventaire.inventaire()
hotes = dict((inv.get("_meta") or {}).get("hostvars") or {})
etiquette = "SITE"
else:
from inventory_rules import instance_courante
chemin = instance_courante().resolve()
fichier = next(iter(sorted((chemin / "inventories").glob("*/hosts.yml"))), None)
if not fichier:
return {}, ""
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
enfants = (data.get("all") or {}).get("children") or {}
hotes = {}
for etat in ("hotes_actifs", "hotes_planifies"):
for nom, v in ((enfants.get(etat) or {}).get("hosts") or {}).items():
hotes[nom] = v or {}
etiquette = chemin.name
porteurs: dict[str, tuple[str, str]] = {}
for nom, v in hotes.items():
adresse = str(v.get("ansible_host") or "")
for fqdn in (v.get("sans_exposition") or []):
if adresse:
porteurs[str(fqdn)] = (adresse, str(nom))
if porteurs:
return porteurs, etiquette
# PAS D'EDGE : LA MACHINE SERT SON PROPRE TLS (le cas du SITE).
#
# `sans_exposition` est pose sur l'hote dont les groupes contiennent l'EDGE de
# l'exposition. Le site n'en a aucun — `site-forge-01` ecoute lui-meme sur 443. Le
# porteur se derive alors de l'application : son `groupe` dit quel hote la sert.
# Deux formes de terminaison, une seule question posee.
import underlay as underlay_mod
apps = (underlay_mod.lire_plan_site("applications.yml") or {}).get("applications") or {}
hote_du_groupe: dict[str, tuple[str, str]] = {}
for nom, v in hotes.items():
adresse = str(v.get("ansible_host") or "")
if adresse:
hote_du_groupe.setdefault(str(nom), (adresse, str(nom)))
for app in apps.values():
if not isinstance(app, dict):
continue
cible = hote_du_groupe.get(str(app.get("hote") or ""))
if not cible:
continue
for fqdn in (app.get("expose") or []):
porteurs[str(fqdn)] = cible
return porteurs, etiquette
def main() -> int:
site = "--site" in sys.argv[1:]
porteurs, etiquette = _expositions_et_porteurs(site)
if not porteurs:
print("Aucune exposition derivee : rien a comparer. "
"(`make instancier` a-t-il tourne ?)")
return 0
print(f"{etiquette} : {len(porteurs)} exposition(s) declaree(s), "
f"servies sur le port {PORT}.\n")
ecarts: list[str] = []
# Un meme porteur sert souvent plusieurs noms : on ne lit son certificat qu'une fois
# par (adresse, sni) et on garde le SAN pour la comparaison inverse.
san_par_adresse: dict[str, list[str]] = {}
noms_propres: dict[str, str] = {a: n for a, n in porteurs.values()}
for fqdn, (adresse, _porteur) in sorted(porteurs.items()):
san, raison = _san_servi(adresse, fqdn)
if not san:
print(f" {fqdn:<34} {adresse:<14} INJOIGNABLE — {raison}")
ecarts.append(f"{fqdn} : {raison} (depuis CE poste ; "
f"la frontiere peut etre le mur, pas le service)")
continue
san_par_adresse.setdefault(adresse, san)
code = _code_http(adresse, fqdn)
dans_le_san = fqdn in san
etat = "OK" if dans_le_san and code.startswith(("2", "3")) else "ECART"
print(f" {fqdn:<34} {adresse:<14} cert={'oui' if dans_le_san else 'NON'} "
f"http={code} {etat}")
if not dans_le_san:
ecarts.append(f"{fqdn} : le certificat SERVI ne porte pas ce nom — "
f"rejouer `client_pki` sur le porteur ; `serveur_nginx` "
f"seul ne reemet pas le SAN")
elif not code.startswith(("2", "3")):
ecarts.append(f"{fqdn} : le certificat porte le nom mais le vhost rend "
f"{code} — le nom est authentifie, rien ne le sert")
# LE SENS INVERSE : un nom reste dans le SAN apres avoir quitte le plan.
declares = set(porteurs)
for adresse, san in sorted(san_par_adresse.items()):
propre = noms_propres.get(adresse, "")
for nom in san:
if nom in declares:
continue
# LE PORTEUR S'AUTHENTIFIE SOUS SON PROPRE NOM, et ce n'est pas un reste :
# `client_pki` met toujours le FQDN et le nom court de la machine dans le SAN.
# Les confondre avec un vestige de renommage ferait crier ce controle a chaque
# execution — un controle qui crie toujours ne se lit plus.
if propre and nom.split(".")[0] == propre:
continue
ecarts.append(f"{nom} : porte par le certificat de {adresse} alors "
f"qu'aucune application ne l'expose — reste d'un "
f"renommage, encore authentifie")
print()
if ecarts:
print(f"ECART — {len(ecarts)} :")
for e in ecarts:
print(f" - {e}")
return 1
print("Conforme : chaque exposition declaree est servie, sous un certificat qui la "
"porte, et le certificat ne porte rien d'autre.")
return 0
if __name__ == "__main__":
sys.exit(main())