Set-OPS-Public/docs/devis-services.md
Daniel Allaire 0cc04177fb devis des expositions : une vraie requete, depuis deux points de vue
Troisieme devis de service. Chaque expose: du plan repond-il, et si non, ou
ca casse.

- requete HTTPS complete avec la racine de l'AC, jamais un connect() : a
  travers l'OPNsense (anti-spoofing) toute connexion TCP reussit, et en TLS
  le silence apres connect() ne distingue pas un service sain d'un trou.
- deux points de vue : depuis l'edge (edge + dorsal) et depuis le poste
  (DNS + frontiere + edge + dorsal). Leur difference diagnostique.
- un code n'est pas un verdict : mon premier comparateur laissait passer un
  502 des deux cotes. Trouve par le test negatif, pas par la relecture.

Etat : les 6 expositions repondent des deux cotes. Test negatif (frontiere
qui bloque + dorsal tombe) : 2 ecarts nommes distinctement, code 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 07:33:49 -04:00

116 lines
6 KiB
Markdown

# Les devis de service : ce qui tourne correspond-il à ce qui est déclaré ?
> **Instrument ajouté le 2026-08-08**, après une série de défauts qu'aucun test n'avait
> signalés. Lecture seule — il ne modifie rien.
```
make identite-plan # realm, fédération, mappeurs, politique, comptes
make certificats-plan # certificats sur disque contre certificats réellement servis
make expositions-plan # chaque `expose:` du plan répond-il, depuis l'edge et depuis le poste
```
## Le trou qu'il comble
`scripts/prouver.py` porte 30 preuves. 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.
Aucune ne demande au système déployé s'il ressemble à ce que le dépôt annonce.
Les quatre défauts du 2026-08-08 vivaient tous dans cet angle :
| Défaut | Le dépôt disait | La machine portait |
|---|---|---|
| politique de mot de passe | `length(12)…` | *aucune*`abcd` accepté |
| entrée `ppolicy` figée | `pwdMustChange: TRUE` | `FALSE` |
| fédération en écriture | comptes écrits dans LDAP | `syncRegistrations` absent |
| prénom | `givenName` | `cn` (le nom complet) |
Chacun a été trouvé en relisant après avoir écrit. Aucun n'a été signalé.
## Ce qu'il vérifie
Le playbook `playbooks/maintenance/devis-identite.yml` **relève** deux dictionnaires — le
déclaré et le réel — et les dépose en JSON. `scripts/devis_identite.py` **compare**. La
séparation est délibérée : la comparaison en Jinja est illisible, et le dépôt a déjà cette
forme pour les devis réseau (Python raisonne, Ansible relève).
- **realm** — politique de mot de passe, anti-force-brute, « mot de passe oublié », relais SMTP
- **fédération** — mode d'écriture et les six clés de durcissement
- **mappeurs** — chaque attribut LDAP projeté
- **annuaire** — l'entrée `pwdPolicy`, attribut par attribut
- **comptes** — ceux qui n'ont pas d'adresse, pour qui « mot de passe oublié » ne mène nulle part
Le déclaré n'est jamais recopié ici : le playbook charge les défauts du rôle et appelle les
résolveurs. **Un devis qui redéclare ce qu'il vérifie ne vérifie rien.**
## Le devis des certificats
Il traque un défaut connu et récurrent : un certificat renouvelé **sur disque** mais
toujours servi **périmé** depuis la mémoire du service, tant que nginx / postfix / dovecot
/ slapd / step-ca n'ont pas été rechargés.
**Ce qu'il a fallu désapprendre pour l'écrire.** Les certificats step-ca vivent 24 h et le
minuteur les renouvelle toutes les ~14 minutes. Une empreinte servie *différente* de celle
sur disque est donc l'état **normal** : comparer les empreintes aurait produit un
vérificateur qui crie en permanence, et qu'on aurait appris à ignorer. Le signal utile est
l'**échéance de ce qui est réellement servi**, plus l'absence de `client_pki_reload_services`
— car alors rien ne rafraîchira jamais la copie en mémoire.
Il a trouvé quelque chose à sa première exécution : sur `infra-pki-01`, l'autorité
elle-même, le certificat était **expiré depuis plus de huit heures** et le renouvellement
échouait toutes les quatorze minutes sur `'step ca renew' requires the '--ca-url' flag`.
Rien ne le signalait.
## Le devis des expositions
Il pose la question que se pose vraiment un utilisateur : **est-ce que mes services
répondent ?** Et quand la réponse est non, il dit *où* ça casse.
**Une vraie requête, jamais un `connect()`.** À travers l'OPNsense (anti-spoofing), toute
connexion TCP réussit — y compris vers une adresse où aucune machine n'existe. Et « lire
des données après connexion » ne vaut rien en TLS, où c'est le *client* qui parle en
premier : le port 443 d'un edge sain se comporte exactement comme un port mort. Seul le
code de retour d'une requête HTTPS complète tranche.
**Deux points de vue, et c'est leur différence qui diagnostique :**
| Point de vue | Ce qu'il traverse |
|---|---|
| depuis l'edge | edge + dorsal |
| depuis le poste | DNS + frontière + edge + dorsal — le chemin d'un vrai utilisateur |
L'un répond et pas l'autre : ce n'est pas le service, c'est le chemin.
**Un code n'est pas un verdict.** Un 502 ou un 504 *est* une réponse, et pourtant le dorsal
est mort derrière. Le premier jet ne testait que la présence d'un code et laissait donc
passer un dorsal tombé — trouvé par le test négatif, pas par la relecture. Un 302 ou un 401,
en revanche, est un service parfaitement vivant qui redirige vers l'IdP ou exige une
authentification.
## Un piège de construction, à connaître avant d'écrire le prochain
`include_vars` au niveau du *play* **prime sur les `group_vars`**. Charger les défauts d'un
rôle tels quels fait donc mentir le devis : il compare au défaut du dépôt au lieu de la
valeur réellement déclarée par l'inventaire. Le premier jet rapportait
`client_pki_reload_services: []` sur les quatorze hôtes alors que quatre groupes le
déclarent — **le devis portait exactement le défaut qu'il est censé traquer.**
La forme correcte, reprise dans les deux devis :
1. retenir ce que l'inventaire déclare, **avant** tout chargement de défauts ;
2. charger les défauts du rôle (`include_vars`, sans `name:` — les défauts se citent entre
eux, et ces références ne se résolvent plus une fois enfermées dans un dictionnaire) ;
3. réimposer les valeurs de l'inventaire **clé par clé**, en boucle. `set_fact` n'accepte
pas un dictionnaire entier passé en argument libre : il le prend sans erreur et n'en
fait rien — c'est ainsi que le premier correctif a paru fonctionner tout en laissant le
devis mentir.
## Ce qu'ils ne font pas
Ils ne corrigent pas — c'est `make deployer` qui réconcilie. Ils répondent à l'autre
question, et sortent en code 1 s'il y a un écart.
Ils couvrent l'identité et les certificats. Le courriel, la base de données et les
expositions web attendent le même traitement ; le patron est là pour être repris.