serveur_ops : la difference entre une archive et une matrice
Some checks are pending
verifier / verifier (push) Waiting to run

Un ecosysteme pouvait DETENIR son genome sans savoir l'executer. Le poste
d'exploitation porte Ansible epingle, le genome clone depuis SA PROPRE forge, et
une cle SSH qui n'appartient qu'a lui. Il n'emporte ni la voute ni son mot de
passe : la structure se reconstruit depuis la forge, les secrets depuis la
sauvegarde. Deux sources qu'un meme incident n'atteint pas ensemble.

Une machine neuve traverse tout le moteur sans rien heriter, et revele ce qu'un
etat anterieur masquait. Deux defauts silencieux en sont sortis.

setops_plan_dir pointait le lien `instance` en dur : neuf roles lisaient donc le
plan d'une AUTRE instance. Le plancher de patient 0 portait les FQDN de
Chezlepro, et son edge publiait server_name forge.chezlepro.internal. La
variable suit desormais l'inventaire reellement charge.

Trois instances sur quatre declaraient les SAN d'exposition de leur edge ; la
quatrieme et le modele public ne les avaient pas. La forge de patient 0 etait
donc publiee derriere le certificat auto-signe de Debian, sans que rien ne le
signale -- `git clone` fut le premier a refuser, a juste titre. P42 le reclame
maintenant pour tout ecosysteme qui declare un edge.

Le harnais a ecrit la moitie de ce role : 5 echecs sur la piece neuve, aucun
n'empechait le code de tourner, tous la rendaient invisible a la carte, au
graphe et au lecteur. 42 preuves, 0 echec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-23 11:58:10 -04:00
parent 8e125b71cb
commit 665b07ba82
16 changed files with 875 additions and 1 deletions

View file

@ -1,5 +1,132 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-08-23 — `serveur_ops` : la différence entre une archive et une matrice
Un écosystème pouvait **détenir** son génome sans savoir l'exécuter. Les cinq dépôts
vivaient sur sa forge — moteur, plans, modèles, étiquette signée — mais Set-OPS ne
tournait que depuis le poste de son mainteneur. L'écosystème possédait le livre ;
personne, chez lui, ne savait le lire à voix haute.
`serveur_ops` est le poste d'exploitation : Ansible épinglé sur la même famille que celle
qui a servi à construire (core 2.18 — reconstruire avec une version différente, c'est
changer la recette sans le dire), le génome cloné, et une clé SSH propre au poste.
### Le génome vient de SA PROPRE forge
Le poste clone `https://forge.<domaine>/genome/…`, pas la forge parente. Ces dépôts y sont
des miroirs resynchronisés toutes les huit heures : l'écosystème se reconstruit donc
depuis **lui-même**, et non depuis son ascendant. Le clone est anonyme — un secret de
moins sur une machine qui en concentre déjà beaucoup.
### Les deux choses qu'il n'a pas, et qui ne sont pas des oublis
**Le mot de passe de la voûte** : saisi à l'exécution. Une machine détenant à la fois le
plan, l'accès SSH à toute la flotte et la clé des secrets n'a plus aucune profondeur.
**Le fichier de voûte** : les dépôts d'instance excluent `vault.yml` de git. Le génome
cloné porte donc le plan **sans les secrets**. D'où une conséquence qu'il vaut mieux
énoncer que découvrir :
```text
la STRUCTURE se reconstruit depuis la forge
les SECRETS se restaurent depuis la sauvegarde (restic)
```
Deux sources distinctes, qu'un même incident n'atteint pas ensemble.
### Sa clé doit être autorisée à la main
Le poste fabrique sa propre paire, distincte de celle du mainteneur : deux exploitants,
deux révocations possibles. Tant que sa clé publique n'est pas portée aux intrants SSH du
plan, il ne joint aucun hôte. C'est volontairement un geste humain — donner à une machine
le droit d'entrer partout mérite une décision, pas un effet de bord.
### Le harnais a écrit la moitié de ce rôle
Le rôle écrit, `make prouver` a rendu **36 OK, 5 échecs**, tous sur la pièce neuve :
```
P08 aucune couche de deploiement -> couches-deploiement.yml
P09 pair de flux inconnu 'hyperviseur' -> vocabulaire : edge|flotte|externe|...
P29 aucune declaration d'authentification -> meta/authentification.yml
P31 aucun README -> roles/serveur_ops/README.md
P38 le catalogue ne le nomme pas -> docs/catalogue-services.md
```
Aucun de ces cinq oublis n'aurait empêché le rôle de fonctionner. Tous les cinq
l'auraient rendu invisible à la carte, au graphe, à la politique de pare-feu et au
lecteur. Le harnais ne vérifie pas que le code marche : il vérifie que le dépôt sait
encore ce qu'il contient. Retour à **41 OK, 0 échec**.
Au passage, `pair: hyperviseur` a été refusé à juste titre : l'hyperviseur n'est pas dans
l'écosystème, il est de l'autre côté de la frontière — donc `externe`. C'est le seul flux
par lequel un écosystème peut en engendrer un autre.
### Ce que le poste a révélé en naissant
Une machine neuve est un révélateur : elle traverse tout le moteur sans rien hériter d'un
état antérieur. `ops-01` a buté sur cinq obstacles, dont deux étaient des défauts réels et
silencieux du dépôt.
**Le plan lu n'était pas celui déployé.** `setops_plan_dir` valait
`{{ playbook_dir }}/../../instance/plan` — le lien `instance` du moteur, **en dur**. Neuf
rôles lisent cette variable. En déployant patient 0 par `SETOPS_INSTANCE`, ils lisaient
donc le plan de **Chezlepro**. Conséquences constatées sur les machines :
```
/etc/hosts de ops-01 : auth.chezlepro.internal, forge.chezlepro.internal…
nginx de l'edge : server_name forge.chezlepro.internal;
```
Un écosystème publiait les noms d'un autre. La variable est désormais ancrée sur
`{{ inventory_dir }}/../../plan` : le plan qui a **engendré** cet inventaire. Les deux ne
peuvent plus se contredire, et l'expression reste juste par le symlink comme par
`SETOPS_INSTANCE`. Corrigé dans les quatre instances et dans le modèle public.
**L'edge publiait derrière un certificat auto-signé.** Trois instances sur quatre
portaient `group_vars/serveur_nginx.yml` — les SAN d'exposition, le chemin du certificat
step-ca, le rechargement de nginx. La quatrième, plus récente, ne l'avait pas ; le modèle
public non plus. Résultat : `ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem` devant
la forge de patient 0. Le service répondait, la page s'affichait après un avertissement,
`make prouver` était vert. Le premier à refuser fut `git clone` — et il avait raison.
C'est un oubli de recopie, pas un bug : un câblage reproduit à la main finit toujours par
manquer quelque part. D'où **P42 — « L'edge porte les noms qu'il publie »**, qui le
réclame désormais pour chaque écosystème déclarant un edge. Contrôle négatif fait : le
fichier retiré, la preuve passe au rouge.
**Et trois obstacles d'exécution**, sans mystère mais instructifs :
- la frontière ne connaissait pas `ops-01` — normal, il est neuf : `+ alias
SETOPS_PATI29_SERVEUR_OPS`, `+ SETOPS_PATI29_CLIENT_UNBOUND`, 0 retrait ;
- `ansible-galaxy` ne joint pas `galaxy.ansible.com` depuis l'overlay, et **c'est très
bien ainsi**. Ouvrir une règle vers un serveur étranger pour qu'un écosystème sache se
reconstruire aurait été la mauvaise réponse. Le contrôleur télécharge dans son cache et
pousse par SSH — même idiome que `serveur_forgejo` devant son propre tiers. Effet de
bord recherché : le poste devient déployable **hors ligne** ;
- le chemin du contrôleur contient une espace (« Espace Chezlepro/… ») et `command: cmd:`
la lit comme un séparateur. Le message d'erreur ne parlait pas du tout du vrai problème.
Forme `argv` désormais.
### La preuve
Depuis `ops-01`, sous son propre compte :
```
8e125b7 plancher : nommer ce qui est hors de l'ecosysteme le moteur
8c4a39b amont : declarer par quelle adresse patient 0 … son plan
etiquette v2026.08.21 — SSH SIGNATURE presente
make aide -> « Set-OPS — moteur d ecosystemes numeriques souverains »
```
L'écosystème lit son propre génome, depuis sa propre forge, avec son propre Ansible.
### Patient 0
Fonction `ops` (zone Services-infra), machine `ops-01` — adressage dérivé `10.29.19.41`,
VMID 129404101. Pas de `client_backup` : le poste ne détient aucun état propre, tout ce
qu'il porte se recompose depuis la forge.
## 2026-08-23 — Les miroirs du génome, et un nom qui ne résout pas pareil selon d'où on le demande ## 2026-08-23 — Les miroirs du génome, et un nom qui ne résout pas pareil selon d'où on le demande
La copie du génome sur patient 0 était **figée au jour du poussage**. Elle ne l'est plus : La copie du génome sur patient 0 était **figée au jour du poussage**. Elle ne l'est plus :

View file

@ -0,0 +1,78 @@
# Preuve de conformite — Set-OPS — 2026-08-23
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (42 OK · 0 echec · 0 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 31 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 30 rôles, 80 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | playbook: playbooks/proxmox/cloner_vm_debian.yml |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| 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, 31 groupes (inventaire dechiffre et parse). |
| 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 : 24 secret(s) exige(s), tous presents. Voute reelle : 25 cle(s), aucun manque. |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 7 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 51 regles, 15 routes, admin=10.0.0.0/24,10.17.0.0/24,192.168.254.2/32,192.168.255.2/32. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 47 groupe(s), 76 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 4 integration(s) universelle(s) : aucune lacune, aucune recopie (1 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 33 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 24 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 13, 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 | 47 scripts expliques et atteignables, 98 cibles make documentees, 55 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 34 exigence(s) de role, toutes satisfaites (118 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 32 revendication(s) de port, aucune collision entre roles co-localises (33 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 41 document(s) declarent leur lecteur (21 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 30 role(s) serveur/client tous nommes, 31 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 : 43 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 |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-08-23._

View file

@ -49,6 +49,7 @@ Ce que la reconstruction couvre, par capacité :
| Collaboration | `serveur_nextcloud`, `serveur_collabora` | déployés par la reconstruction, base et client OIDC dérivés du plan — **usage** (dépôt de fichier, édition partagée) non consigné comme preuve | | Collaboration | `serveur_nextcloud`, `serveur_collabora` | déployés par la reconstruction, base et client OIDC dérivés du plan — **usage** (dépôt de fichier, édition partagée) non consigné comme preuve |
| Plateforme webapp | `serveur_web_frontal`, `serveur_web_dorsal` | sites statiques et webapps natives (venv + systemd + nginx), **zéro conteneur** | | Plateforme webapp | `serveur_web_frontal`, `serveur_web_dorsal` | sites statiques et webapps natives (venv + systemd + nginx), **zéro conteneur** |
| Sauvegardes | `serveur_backup`, `client_backup` | restic hors-nœud, **restauration éprouvée** (2026-08-12 : la donnée revient) | | Sauvegardes | `serveur_backup`, `client_backup` | restic hors-nœud, **restauration éprouvée** (2026-08-12 : la donnée revient) |
| Exploitation | `serveur_ops` | le poste depuis lequel l'ecosysteme se reconstruit : Ansible epingle, genome clone depuis **sa propre forge**, cle SSH propre — **sans** la voute ni son mot de passe |
| Agents de flotte | `client_metrique`, `client_journal`, `client_smtp` | collecte et relais sur toute la flotte | | Agents de flotte | `client_metrique`, `client_journal`, `client_smtp` | collecte et relais sur toute la flotte |
*Rôles retirés (2026-07-04, supersédés ou hors conception)* : `serveur_sendmail` *Rôles retirés (2026-07-04, supersédés ou hors conception)* : `serveur_sendmail`

View file

@ -61,6 +61,9 @@ couches:
- serveur_nextcloud - serveur_nextcloud
- serveur_web_frontal - serveur_web_frontal
- serveur_web_dorsal - serveur_web_dorsal
# Le poste d'exploitation vient APRES la forge : il clone le genome depuis
# elle. Le placer plus tot le laisserait sans source.
- serveur_ops
- nom: agents - nom: agents
raison: "Les intégrations clientes qui expédient vers les services centraux (métriques, journaux, courriel, sauvegardes, résolution locale). Déployées en dernier, quand leurs cibles sont debout." raison: "Les intégrations clientes qui expédient vers les services centraux (métriques, journaux, courriel, sauvegardes, résolution locale). Déployées en dernier, quand leurs cibles sont debout."

View file

@ -81,6 +81,25 @@ groupes:
raison: "Forgejo depend d'une base (serveur ou fichier) et d'une publication HTTP(S) ; le courriel est un agrement." raison: "Forgejo depend d'une base (serveur ou fichier) et d'une publication HTTP(S) ; le courriel est un agrement."
surveillance: "Verifier HTTP(S), base, files Git et envoi courriel." surveillance: "Verifier HTTP(S), base, files Git et envoi courriel."
serveur_ops:
requiert_groupes_actifs:
- serveur_forgejo
# LE POSTE LIT LE GENOME SUR LA FORGE DE SON PROPRE ECOSYSTEME — c'est ce qui le rend
# autonome : il ne redemande rien a son parent. Sans forge, il n'a aucune source.
#
# Une forge EXTERNE reste possible (un ecosysteme peut lire le genome ailleurs) : il
# suffit de surcharger `serveur_ops_forge_url`. L'exigence tombe alors, comme pour
# toute exigence conditionnelle du registre.
sauf_si:
serveur_forgejo: { variable: serveur_ops_forge_externe, vaut: true }
# UTILISE SI PRESENT : sans confiance PKI, `git clone` refuse le certificat de la
# forge — et il a raison de refuser. Ce n'est pas une exigence du groupe : une forge
# a certificat public se cloner sans client_pki.
utilise_si_present:
- serveur_step_ca
raison: "Le poste d'exploitation clone le genome depuis la forge de l'ecosysteme ; sans elle, il n'a pas de source."
surveillance: "Verifier que les depots clones suivent leur amont et qu'ansible repond dans le venv."
serveur_nextcloud: serveur_nextcloud:
requiert_groupes_actifs: requiert_groupes_actifs:
- serveur_postgresql - serveur_postgresql

View file

@ -16,4 +16,19 @@ domaine_interne: "exemple.internal"
fuseau_horaire: "America/Toronto" fuseau_horaire: "America/Toronto"
organisation: "Exemple" organisation: "Exemple"
identite_realm: "exemple" identite_realm: "exemple"
setops_plan_dir: "{{ playbook_dir }}/../../instance/plan" # LE PLAN SUIT L'INVENTAIRE, PAS LE SYMLINK (2026-08-23).
#
# La valeur precedente etait `{{ playbook_dir }}/../../instance/plan` : le lien
# `instance` du moteur, en dur. Tout role lisant le plan lisait donc celui de
# l'instance POINTEE PAR LE LIEN, et non celle qu'on deploie.
#
# CE QUE CA A DONNE. En deployant patient 0 avec `SETOPS_INSTANCE`, le plancher
# /etc/hosts de `ops-01` a recu les FQDN de CHEZLEPRO -- auth.chezlepro.internal,
# forge.chezlepro.internal... -- pointes sur l'edge de patient 0. Un ecosysteme
# annoncait les noms d'un autre. Neuf roles lisent cette variable ; le plancher est
# simplement celui qui l'a rendu visible.
#
# `inventory_dir` est le dossier de l'inventaire REELLEMENT charge. Le plan qui a
# engendre cet inventaire est son voisin : les deux ne peuvent plus se contredire,
# et l'expression reste juste qu'on passe par le symlink ou par SETOPS_INSTANCE.
setops_plan_dir: "{{ inventory_dir }}/../../plan"

View file

@ -0,0 +1,24 @@
---
# L'EDGE DOIT PORTER LES NOMS QU'IL PUBLIE (2026-08-23).
#
# Sans ce fichier, `client_pki` n'emet le certificat de l'edge qu'avec ses propres noms
# (`infra-edge-01.genese.internal`), et nginx retombe sur le certificat auto-signe de
# Debian pour tout FQDN expose. Le service repond, la page s'affiche apres un
# avertissement — et rien ne signale la panne. C'est ce qui s'est passe ici : la forge de
# patient 0 etait publiee derriere un `ssl-cert-snakeoil.pem`, et `git clone` a ete le
# premier a refuser, a juste titre.
#
# Ce fichier appartient au MODELE parce que trois instances sur quatre le portaient
# et que la quatrieme, plus recente, ne l'avait pas : un cablage recopie a la main
# finit toujours par manquer quelque part. La preuve P42 le verifie desormais.
#
# `sans_exposition` est derive du plan (les `expose:` des applications).
client_pki_sans: >-
{{ ([ansible_fqdn | default(ansible_hostname), ansible_hostname, ansible_host]
+ (sans_exposition | default([])))
| select | unique | list }}
serveur_nginx_certificat: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.crt"
serveur_nginx_cle: "/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.key"
# Un cert renouvele sur disque reste PERIME en memoire tant que nginx n'a pas recharge.
client_pki_reload_services:
- nginx

View file

@ -0,0 +1,22 @@
---
- name: Appliquer le groupe serveur_ops
hosts: serveur_ops
become: true
# Le verrou dpkg est tenu par les maj automatiques de Debian, par vagues, plusieurs
# minutes apres le premier demarrage. Pose ICI plutot que dans chaque role : toute
# tache apt du play en herite, y compris celles des roles inclus.
module_defaults:
ansible.builtin.apt:
lock_timeout: 300
gather_facts: true
pre_tasks:
- name: Vérifier que la cible est Debian
ansible.builtin.assert:
that:
- ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour Debian."
roles:
- serveur_ops

100
roles/serveur_ops/README.md Normal file
View file

@ -0,0 +1,100 @@
# serveur_ops
**Poste d'exploitation** : la machine depuis laquelle l'écosystème se reconstruit
lui-même. Ansible épinglé, le génome cloné depuis **sa propre forge**, et une clé SSH
qui n'appartient qu'à lui.
## Principe
Un écosystème pouvait jusqu'ici **détenir** son génome sans savoir l'exécuter. Les cinq
dépôts vivaient sur sa forge — moteur, plans, modèles, étiquette signée — mais Set-OPS
ne tournait que depuis le poste de son mainteneur. L'écosystème possédait le livre ;
personne, chez lui, ne savait le lire à voix haute.
`serveur_ops` est la différence entre une **archive** et une **matrice**.
## Ce que le poste emporte
```text
/opt/setops/
Set-OPS-public/ le moteur ← cloné depuis genome/set-ops-public
OPS-<instance>/ le plan ← cloné depuis genome/ops-<instance>
venv/ ansible-core 2.18 (épinglé)
.ssh/id_ed25519 la clé du poste
LISEZ-MOI.md mode d'emploi, généré
```
Le moteur et les instances sont des **dossiers frères**, comme sur le poste du
mainteneur : c'est cette fraternité que `scripts/instances.py` découvre pour proposer
les instances pilotables. Le lien `instance` désigne celle qu'on pilote.
## D'où vient le génome : de sa propre forge
Le poste clone depuis `https://forge.<domaine>/genome/…`, **pas** depuis la forge
parente. Les dépôts y sont des miroirs, resynchronisés toutes les huit heures : la copie
est vivante, et l'écosystème se reconstruit depuis **lui-même**.
Le clone est **anonyme** — les dépôts du génome sont lisibles sur la forge interne. Un
secret de moins sur une machine qui en concentre déjà beaucoup.
La confiance TLS vient de `client_pki` (racine step-ca dans le magasin système). Sans
elle, `git clone` refuse le certificat de la forge, et il a raison de refuser.
## Les deux choses qu'il n'a PAS
**Le mot de passe de la voûte.** Il se saisit à l'exécution. Une machine qui détiendrait
à la fois le plan, l'accès SSH à toute la flotte et la clé des secrets n'aurait plus
aucune profondeur : la compromettre serait compromettre l'écosystème entier.
**Le fichier de voûte lui-même.** Les dépôts d'instance excluent `vault.yml` de git —
c'est la règle « aucun secret en clair ». Le génome cloné ici porte donc le plan **sans
les secrets**.
Conséquence à connaître, et qui n'est pas un défaut mais une conception :
```text
la STRUCTURE de l'écosystème se reconstruit depuis la forge
les SECRETS se restaurent depuis la sauvegarde (restic)
```
Deux sources distinctes, qu'un même incident n'atteint pas ensemble.
## Sa clé doit être autorisée
Le poste fabrique sa propre paire, distincte de celle du mainteneur : deux exploitants,
deux révocations possibles, et l'accès du poste se lit dans les `authorized_keys` de la
flotte au lieu de se confondre avec celui d'un humain.
Tant que cette clé publique n'est pas portée aux intrants SSH du plan, **le poste ne
joint aucun hôte**. Le rôle l'affiche au déploiement ; elle se relit à tout moment :
```bash
cat /opt/setops/.ssh/id_ed25519.pub
```
C'est volontairement un geste humain : donner à une machine le droit d'entrer partout
mérite une décision, pas un effet de bord.
## Variables principales
| Variable | Défaut | Rôle |
|---|---|---|
| `serveur_ops_utilisateur` | `setops` | compte d'exploitation |
| `serveur_ops_racine` | `/opt/setops` | racine du poste |
| `serveur_ops_ansible` | `ansible-core>=2.18,<2.19` | version **épinglée** |
| `serveur_ops_forge_url` | `https://forge.<domaine>` | source du génome |
| `serveur_ops_depots` | moteur + instance | quoi cloner, et où |
| `serveur_ops_instance` | l'instance du plan | cible du lien `instance` |
| `serveur_ops_cle_generer` | `true` | fabriquer la clé du poste |
## Dépendances
`serveur_forgejo` (la source du génome) — déclaré dans `docs/dependances-groupes.yml`,
avec `sauf_si serveur_ops_forge_externe` pour l'écosystème qui lit son génome ailleurs.
`serveur_step_ca` est **utilisé si présent** : sans confiance PKI, le clone échoue sur
le certificat.
## Ce que ce rôle ne fait pas
Il n'ouvre aucun service, n'écoute sur aucun port, ne publie rien à l'edge. Il ne
sauvegarde rien non plus : tout ce qu'il contient se recompose depuis la forge.

View file

@ -0,0 +1,81 @@
---
# LE POSTE D'EXPLOITATION — la machine depuis laquelle l'écosystème se reconstruit.
#
# Jusqu'ici, Set-OPS ne s'exécutait que depuis le poste de son mainteneur. L'écosystème
# détenait donc la recette (le génome, sur sa forge) sans que personne, chez lui, ne
# sache la lire à voix haute. `serveur_ops` est la cuisine : ansible, le génome cloné
# depuis SA PROPRE forge, et de quoi rejouer le moteur.
#
# CE QU'IL N'EMPORTE PAS, ET NE DOIT PAS EMPORTER : le mot de passe de la voûte. Il est
# saisi à l'exécution. Une machine qui détient à la fois le plan, l'accès SSH à la flotte
# et la clé des secrets n'a plus aucune profondeur : sa compromission est celle de tout
# l'écosystème. Voir README.md du rôle.
serveur_ops_utilisateur: "setops"
serveur_ops_racine: "/opt/setops"
serveur_ops_venv: "{{ serveur_ops_racine }}/venv"
# Paquets du poste. `make` parce que l'interface opérateur de Set-OPS est un Makefile ;
# `git` parce que le génome vit dans des dépôts ; `rsync` pour les transferts de fichiers
# d'Ansible sur les gros arbres.
serveur_ops_paquets:
- git
- make
- python3-venv
- python3-pip
- openssh-client
- rsync
- ca-certificates
# Ansible est ÉPINGLÉ sur la même famille que le poste du mainteneur (core 2.18) : un
# écosystème qui se reconstruit avec une version différente de celle qui l'a construit ne
# reproduit pas la même chose, et l'écart ne se voit qu'au premier échec.
serveur_ops_ansible: "ansible-core>=2.18,<2.19"
# --- D'OÙ VIENT LE GÉNOME ----------------------------------------------------
#
# DE SA PROPRE FORGE, pas de l'amont. C'est tout l'objet des miroirs : la forge de
# l'écosystème détient une copie vivante du génome, resynchronisée toutes les huit
# heures. Le poste d'exploitation lit CETTE copie — l'écosystème se reconstruit donc
# depuis lui-même, et non depuis son parent.
#
# Dérivé du groupe `serveur_forgejo` de l'inventaire. Surchargeable pour une forge
# externe au plan.
serveur_ops_forge_hote: >-
{{ (groups['serveur_forgejo'] | default([]) | first | default('')) }}
serveur_ops_forge_url: >-
https://forge.{{ domaine_interne }}
serveur_ops_forge_organisation: "genome"
# Les dépôts du génome, et où ils atterrissent. Les noms sont ceux que la forge porte
# (minuscules) ; les destinations reprennent la disposition du poste du mainteneur, où
# le moteur et les instances sont des dossiers frères — c'est cette fraternité que
# `scripts/instances.py` découvre.
serveur_ops_depots:
- { depot: "set-ops-public", dest: "Set-OPS-public", role: "moteur" }
- { depot: "ops-patient0", dest: "OPS-Patient0", role: "instance" }
# L'instance que le poste pilote par défaut (symlink `instance` du moteur).
serveur_ops_instance: "OPS-Patient0"
# --- CLÉ SSH DU POSTE --------------------------------------------------------
#
# Le poste a besoin d'atteindre toute la flotte en SSH. Il se fabrique donc sa PROPRE
# paire, distincte de celle du mainteneur : deux exploitants, deux clés, deux révocations
# possibles. La clé publique produite doit être autorisée sur la flotte — le rôle
# l'affiche et l'écrit dans un fichier prévu pour être repris au plan (voir README).
serveur_ops_cle_type: "ed25519"
serveur_ops_cle_generer: true
# --- CACHE DES COLLECTIONS (côté CONTRÔLEUR) ---------------------------------
#
# Le poste ne va PAS chercher ses collections sur galaxy.ansible.com : la frontière ne
# laisse pas passer ce flux, et ouvrir une règle vers un serveur étranger pour qu'un
# écosystème sache se reconstruire serait la mauvaise réponse. Le contrôleur télécharge
# une fois dans son cache, puis pousse par SSH — même idiome que `serveur_forgejo`
# devant son propre tiers.
#
# Effet de bord recherché : le poste devient déployable **hors ligne**.
serveur_ops_cache_collections: >-
{{ (setops_cache_artefacts | default(lookup('env', 'HOME') + '/.cache/setops')) }}/collections
serveur_ops_requirements_controleur: "{{ playbook_dir }}/../../requirements.yml"

View file

@ -0,0 +1,13 @@
---
# Position de ce role dans la directive d'authentification (D-38..D-41).
# Voir docs/authentification.md. Gardee par la preuve P29.
authentification:
portee: sans-auth-humaine
mecanisme: aucun
formulaire_local: sans-objet
secours: "Acces SSH a l'hote, puis `sudo -iu setops`"
raison: >-
Le poste d'exploitation n'expose aucune interface : il n'est ni serveur web ni
service reseau, et rien ne s'y connecte. On y entre par SSH sur l'hote, comme sur
n'importe quelle machine de la flotte, puis l'on devient l'utilisateur
d'exploitation. Il n'y a donc rien a federer, et rien a fermer.

View file

@ -0,0 +1,8 @@
---
# Empreinte ressources — un poste d'exploitation ne sert personne, il exécute. Ansible
# est gourmand en processus courts (un fork par hôte × forks) mais pas en mémoire
# résidente ; le disque porte le génome et les collections.
setops_empreinte:
coeurs: 2
memoire_mo: 2048
disque_go: 20

View file

@ -0,0 +1,27 @@
---
# Flux réseau du poste d'exploitation. Voir docs/flux-conception.md.
#
# Le poste est presque tout en sortie : il va chercher son génome, puis il pilote. Rien
# ne s'y connecte, sinon l'administration elle-même.
flux:
- sens: egress
port: 443
protocole: tcp
pair: edge
chiffrement: tls-requis
raison: "Cloner et resynchroniser le génome depuis la forge de l'écosystème."
- sens: egress
port: 22
protocole: tcp
pair: flotte
chiffrement: ssh
raison: "Piloter la flotte — c'est la raison d'être du poste."
# L'HYPERVISEUR N'EST PAS DANS L'ÉCOSYSTÈME : il appartient au monde physique, de
# l'autre côté de la frontière. C'est donc un pair `externe`, et ce flux est le seul
# par lequel un écosystème peut en engendrer un autre.
- sens: egress
port: 8006
protocole: tcp
pair: externe
chiffrement: tls-requis
raison: "API de l'hyperviseur : créer et cloner les VM d'un écosystème descendant."

View file

@ -0,0 +1,227 @@
---
- name: Exiger les intrants du poste d'exploitation
ansible.builtin.assert:
that:
- domaine_interne | default('') | length > 0
- serveur_ops_depots | length > 0
fail_msg: >-
serveur_ops exige `domaine_interne` et au moins un dépôt dans
`serveur_ops_depots` (le moteur au minimum).
- name: Installer l'outillage du poste
ansible.builtin.apt:
name: "{{ serveur_ops_paquets }}"
state: present
update_cache: true
when: not ansible_check_mode
- name: Créer l'utilisateur d'exploitation
ansible.builtin.user:
name: "{{ serveur_ops_utilisateur }}"
home: "{{ serveur_ops_racine }}"
shell: /bin/bash
create_home: true
system: false
- name: Poser les droits de la racine d'exploitation
ansible.builtin.file:
path: "{{ serveur_ops_racine }}"
state: directory
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0750"
# L'ENVIRONNEMENT PYTHON EST ISOLÉ, PAS SYSTÈME. Debian gère ansible en paquet, mais la
# version qu'il propose suit son propre calendrier : reconstruire un écosystème avec un
# ansible plus récent que celui qui l'a construit, c'est changer la recette sans le dire.
# Le venv permet d'épingler exactement la famille utilisée par le mainteneur.
- name: Créer l'environnement Python isolé
ansible.builtin.command:
cmd: "python3 -m venv {{ serveur_ops_venv }}"
creates: "{{ serveur_ops_venv }}/bin/python"
become: true
become_user: "{{ serveur_ops_utilisateur }}"
- name: Installer Ansible dans l'environnement isolé
ansible.builtin.pip:
name:
- "{{ serveur_ops_ansible }}"
- "pyyaml"
virtualenv: "{{ serveur_ops_venv }}"
state: present
become: true
become_user: "{{ serveur_ops_utilisateur }}"
when: not ansible_check_mode
register: serveur_ops_pip
retries: 3
delay: 6
until: serveur_ops_pip is succeeded
# --- LE GÉNOME, DEPUIS SA PROPRE FORGE ---------------------------------------
#
# Cloné en anonyme : les dépôts du génome sont lisibles sur la forge de l'écosystème, et
# le poste n'a donc AUCUN justificatif à détenir pour se reconstruire. C'est délibéré —
# un secret de moins sur la machine qui en concentre déjà beaucoup.
#
# La confiance TLS vient de `client_pki` (racine step-ca dans le magasin système). Sans
# lui, `git clone` refuse le certificat de la forge — et c'est bien qu'il refuse.
- name: Cloner le génome depuis la forge de l'écosystème
ansible.builtin.git:
repo: "{{ serveur_ops_forge_url }}/{{ serveur_ops_forge_organisation }}/{{ item.depot }}.git"
dest: "{{ serveur_ops_racine }}/{{ item.dest }}"
version: "{{ item.branche | default('main') }}"
update: true
force: false
loop: "{{ serveur_ops_depots }}"
loop_control:
label: "{{ item.depot }} → {{ item.dest }}"
become: true
become_user: "{{ serveur_ops_utilisateur }}"
when: not ansible_check_mode
register: serveur_ops_clone
retries: 3
delay: 6
until: serveur_ops_clone is succeeded
# --- LES COLLECTIONS, SANS DEPENDRE DE GALAXY ---------------------------------
#
# UN POSTE D'EXPLOITATION QUI APPELLE galaxy.ansible.com POUR SE CONSTRUIRE N'EST PAS
# SOUVERAIN. Mesure du 2026-08-23, premier deploiement : `ansible-galaxy collection
# install` a echoue depuis l'overlay de patient 0 —
# `SSL: UNEXPECTED_EOF_WHILE_READING` — parce que la frontiere ne laisse pas passer ce
# flux, et c'est tres bien ainsi. Ouvrir une regle vers un serveur americain pour que
# l'ecosysteme sache se reconstruire aurait ete la mauvaise reponse.
#
# MEME IDIOME QUE serveur_forgejo DEVANT SON PROPRE TIERS : le CONTROLEUR telecharge une
# fois, dans son cache, puis pousse par SSH. Aucun flux nouveau depuis la cible, et
# l'artefact devient deployable hors ligne — un poste peut se reconstruire sans internet
# du tout, ce qui est le sens meme d'une lignee autonome.
- name: Preparer le cache des collections sur le controleur
ansible.builtin.file:
path: "{{ serveur_ops_cache_collections }}"
state: directory
mode: "0755"
delegate_to: localhost
become: false
run_once: true
# `argv` ET NON `cmd` : le chemin du controleur peut contenir une ESPACE (« Espace
# Chezlepro/… » chez le mainteneur), et `cmd` la lit comme un separateur d'arguments.
# Constate le 2026-08-23 : ansible-galaxy recevait « /home/…/Espace » puis
# « Chezlepro/… » comme deux arguments, et refusait — « positional collection_name arg
# and --requirements-file are mutually exclusive ». Le message ne parlait pas du tout du
# vrai probleme.
- name: Telecharger les collections dans le cache du controleur (une seule fois)
ansible.builtin.command:
argv:
- "ansible-galaxy"
- "collection"
- "download"
- "-r"
- "{{ serveur_ops_requirements_controleur }}"
- "-p"
- "{{ serveur_ops_cache_collections }}"
creates: "{{ serveur_ops_cache_collections }}/requirements.yml"
delegate_to: localhost
become: false
run_once: true
when: not ansible_check_mode
- name: Deposer les collections sur le poste
ansible.builtin.copy:
src: "{{ serveur_ops_cache_collections }}/"
dest: "{{ serveur_ops_racine }}/.collections-hors-ligne/"
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0644"
directory_mode: "0755"
when: not ansible_check_mode
# `chdir` OBLIGATOIRE : le `requirements.yml` produit par `collection download` nomme les
# archives en RELATIF (`community-postgresql-4.2.0.tar.gz`), et ansible-galaxy les cherche
# depuis le repertoire COURANT, pas depuis celui du fichier. Sans chdir : « Could not find
# community-postgresql-4.2.0.tar.gz » alors que l'archive est bien la, a cote.
- name: Installer les collections depuis le depot local (hors ligne)
ansible.builtin.command:
cmd: >-
{{ serveur_ops_venv }}/bin/ansible-galaxy collection install
-r requirements.yml
-p {{ serveur_ops_racine }}/.ansible/collections
chdir: "{{ serveur_ops_racine }}/.collections-hors-ligne"
creates: "{{ serveur_ops_racine }}/.ansible/collections/ansible_collections/community/general"
become: true
become_user: "{{ serveur_ops_utilisateur }}"
when: not ansible_check_mode
# LES DEUX SYMLINKS (D-80). `instance` dit QUEL tenant on pilote. Le poste en pose un par
# défaut ; l'exploitant le bascule comme sur le poste du mainteneur.
- name: Désigner l'instance pilotée par défaut
ansible.builtin.file:
src: "{{ serveur_ops_racine }}/{{ serveur_ops_instance }}"
dest: >-
{{ serveur_ops_racine }}/{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | first).dest }}/instance
state: link
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
force: true
when: not ansible_check_mode
# --- LA CLÉ DU POSTE ---------------------------------------------------------
#
# Le poste se fabrique sa propre paire, distincte de celle du mainteneur. Deux
# exploitants, deux clés : on peut révoquer l'un sans couper l'autre — et l'accès du
# poste se lit dans les `authorized_keys` de la flotte, au lieu de se confondre avec
# celui d'un humain.
#
# `ssh-keygen` plutôt que `community.crypto.openssh_keypair` : le dépôt ne requiert que
# `community.postgresql` et `community.general`, et fabriquer une paire de clés ne
# justifie pas d'imposer une collection de plus à tout écosystème qui déploie un poste.
- name: Assurer le répertoire SSH du poste
ansible.builtin.file:
path: "{{ serveur_ops_racine }}/.ssh"
state: directory
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0700"
- name: Fabriquer la clé SSH du poste
ansible.builtin.command:
cmd: >-
ssh-keygen -t {{ serveur_ops_cle_type }} -N ''
-C "setops@{{ inventory_hostname }}.{{ domaine_interne }}"
-f {{ serveur_ops_racine }}/.ssh/id_{{ serveur_ops_cle_type }}
creates: "{{ serveur_ops_racine }}/.ssh/id_{{ serveur_ops_cle_type }}"
become: true
become_user: "{{ serveur_ops_utilisateur }}"
when:
- serveur_ops_cle_generer | bool
- not ansible_check_mode
register: serveur_ops_cle
- name: Relire la clé publique du poste
ansible.builtin.slurp:
src: "{{ serveur_ops_racine }}/.ssh/id_{{ serveur_ops_cle_type }}.pub"
register: serveur_ops_pub
when:
- serveur_ops_cle_generer | bool
- not ansible_check_mode
# ÉCRIRE, PUIS RELIRE (D-68) — et rendre le résultat EXPLOITABLE. Cette clé ne sert à
# rien tant qu'elle n'est pas autorisée sur la flotte : on l'affiche pour que l'exploitant
# la reprenne dans les intrants, au lieu de la laisser dormir sur le disque.
- name: La clé publique à autoriser sur la flotte
ansible.builtin.debug:
msg:
- "Clé publique du poste d'exploitation — à ajouter aux intrants SSH du plan :"
- "{{ serveur_ops_pub.content | b64decode | trim }}"
when:
- serveur_ops_cle_generer | bool
- not ansible_check_mode
- name: Déposer le mode d'emploi sur le poste
ansible.builtin.template:
src: LISEZ-MOI.md.j2
dest: "{{ serveur_ops_racine }}/LISEZ-MOI.md"
owner: "{{ serveur_ops_utilisateur }}"
group: "{{ serveur_ops_utilisateur }}"
mode: "0644"

View file

@ -0,0 +1,58 @@
# Poste d'exploitation de {{ organisation | default('cet écosystème') }}
> Fichier **généré** par le rôle `serveur_ops`. Ne pas éditer à la main.
Cette machine sait reconstruire l'écosystème. Elle porte le moteur Set-OPS, le plan, et
un Ansible épinglé sur la même famille que celle qui a servi à le construire.
## Ce qu'elle contient
```text
{{ serveur_ops_racine }}/
{% for d in serveur_ops_depots %}
{{ "%-22s" | format(d.dest) }} {{ d.role }} — cloné depuis {{ serveur_ops_forge_url }}/{{ serveur_ops_forge_organisation }}/{{ d.depot }}.git
{% endfor %}
venv/ Ansible {{ serveur_ops_ansible }}
.ssh/id_{{ serveur_ops_cle_type }} la clé propre au poste
```
Le génome vient de **la forge de cet écosystème**, pas de son parent. Ces dépôts y sont
des miroirs, resynchronisés automatiquement : le poste lit donc une copie vivante.
## S'en servir
```bash
sudo -iu {{ serveur_ops_utilisateur }}
cd {{ serveur_ops_racine }}/{{ (serveur_ops_depots | selectattr('role', 'eq', 'moteur') | first).dest }}
export PATH={{ serveur_ops_venv }}/bin:$PATH
export ANSIBLE_COLLECTIONS_PATH={{ serveur_ops_racine }}/.ansible/collections
make aide
```
L'instance pilotée est désignée par le lien `instance` → actuellement
`{{ serveur_ops_instance }}`. On la bascule comme sur n'importe quel poste Set-OPS.
## Les deux choses qu'il n'a PAS, et pourquoi
**Le mot de passe de la voûte.** Il se saisit à l'exécution. Une machine qui détiendrait
à la fois le plan, l'accès SSH à toute la flotte et la clé des secrets n'aurait plus
aucune profondeur : la compromettre, ce serait compromettre l'écosystème entier. Le mot
de passe reste dans une tête, ou dans un coffre hors de cette machine.
**Le fichier de voûte lui-même.** Les dépôts d'instance excluent `vault.yml` de git —
c'est la règle « aucun secret en clair ». Le génome cloné ici porte donc le plan **sans
les secrets**. Pour un déploiement réel, la voûte se restaure depuis la sauvegarde
(`restic`), pas depuis la forge.
Autrement dit : ce poste reproduit la **structure** de l'écosystème de mémoire, et ses
**secrets** depuis la sauvegarde. Deux sources distinctes, et c'est voulu.
## Sa clé doit être autorisée
Le poste s'est fabriqué sa propre paire SSH, distincte de celle du mainteneur — deux
exploitants, deux révocations possibles. Tant que sa clé publique n'est pas dans les
intrants SSH du plan, il ne joint aucun hôte. La clé publique :
```bash
cat {{ serveur_ops_racine }}/.ssh/id_{{ serveur_ops_cle_type }}.pub
```

View file

@ -829,6 +829,75 @@ def preuve_resolution_unique() -> tuple[bool, str]:
f"`inventory_rules`, {len(exemptes) - 1} exemption(s) nommee(s).") f"`inventory_rules`, {len(exemptes) - 1} exemption(s) nommee(s).")
def preuve_edge_porte_ses_noms() -> tuple[bool, str]:
"""Tout ecosysteme qui PUBLIE des noms sert un certificat qui les porte.
POURQUOI CETTE PREUVE EXISTE (2026-08-23). L'edge de patient 0 publiait sa forge
derriere `ssl-cert-snakeoil.pem` — le certificat auto-signe de Debian. Le service
repondait, la page s'affichait apres un avertissement, `make prouver` etait vert :
rien, nulle part, ne signalait que la publication n'etait pas de confiance. Le premier
a refuser fut `git clone`, en voulant cloner le genome — et il avait raison.
LA CAUSE N'ETAIT PAS UN BUG, C'ETAIT UN OUBLI DE RECOPIE. Trois instances sur quatre
portaient `group_vars/serveur_nginx.yml` (les SAN d'exposition, le chemin du cert
step-ca, le rechargement de nginx). La quatrieme, plus recente, ne l'avait pas — et le
modele public dont descendent les prochaines ne l'avait pas non plus. Un cablage
recopie a la main finit toujours par manquer quelque part ; la seule parade est qu'une
preuve le reclame.
CE QU'ELLE TESTE, hors ligne, pour CHAQUE instance qui declare un groupe
`serveur_nginx` :
- le fichier `group_vars/serveur_nginx.yml` existe ;
- `client_pki_sans` y etend les SAN avec `sans_exposition` (les `expose:` du plan) ;
- `serveur_nginx_certificat` pointe vers les certificats de l'AC, pas ailleurs.
CE QU'ELLE NE TESTE PAS : ce que l'edge sert REELLEMENT. Cela demande de joindre la
machine — c'est le travail de `make certificats-plan`. Ici on verifie que l'ecosysteme
a DEMANDE la bonne chose.
"""
sys.path.insert(0, str(RACINE / "scripts"))
import instances as mod_instances
trouvees = mod_instances.decouvrir()
if not trouvees:
return True, "Aucune instance decouverte : rien a verifier."
fautes: list[str] = []
verifiees: list[str] = []
for i in trouvees:
base = RACINE.parent / i["nom"]
inventaires = sorted((base / "inventories").glob("*")) if (base / "inventories").is_dir() else []
for inv in inventaires:
if not inv.is_dir():
continue
hosts = inv / "hosts.yml"
if not hosts.exists():
continue
# Un edge n'est requis que si l'ecosysteme en declare un.
if "serveur_nginx" not in hosts.read_text(encoding="utf-8", errors="ignore"):
continue
f = inv / "group_vars" / "serveur_nginx.yml"
nom = f"{i['nom']}/{inv.name}"
if not f.exists():
fautes.append(f"{nom} : aucun group_vars/serveur_nginx.yml — l'edge n'emettra "
f"pas de cert pour les noms qu'il publie")
continue
texte = f.read_text(encoding="utf-8", errors="ignore")
if "sans_exposition" not in texte:
fautes.append(f"{nom} : client_pki_sans n'etend pas les SAN avec `sans_exposition`")
if "/etc/step/certs/" not in texte:
fautes.append(f"{nom} : serveur_nginx_certificat ne pointe pas vers l'AC interne")
verifiees.append(nom)
if fautes:
return False, (f"{len(fautes)} ecosysteme(s) publient sans porter leurs noms : "
+ " | ".join(fautes[:4]) + ("…" if len(fautes) > 4 else ""))
if not verifiees:
return True, "Aucun ecosysteme ne declare d'edge : rien a publier."
return True, (f"{len(verifiees)} edge(s) emettent un certificat portant les noms publies "
f"({', '.join(verifiees)}).")
def preuve_parente_inscrite() -> tuple[bool, str]: def preuve_parente_inscrite() -> tuple[bool, str]:
"""L'ecosysteme sait de QUOI il descend, et cette filiation tient encore. """L'ecosysteme sait de QUOI il descend, et cette filiation tient encore.
@ -1195,6 +1264,8 @@ PREUVES: list[dict] = [
"func": preuve_parente_inscrite}, "func": preuve_parente_inscrite},
{"id": "P41", "titre": "Resolution d'instance : une seule, partagee", "refs": [], {"id": "P41", "titre": "Resolution d'instance : une seule, partagee", "refs": [],
"func": preuve_resolution_unique}, "func": preuve_resolution_unique},
{"id": "P42", "titre": "L'edge porte les noms qu'il publie", "refs": [],
"func": preuve_edge_porte_ses_noms},
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [], {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],
"cmds": [[sys.executable, "scripts/verifier_ports.py"]]}, "cmds": [[sys.executable, "scripts/verifier_ports.py"]]},
] ]