diff --git a/CHANGELOG.md b/CHANGELOG.md index 2348172..478b4f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,132 @@ # 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./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 La copie du génome sur patient 0 était **figée au jour du poussage**. Elle ne l'est plus : diff --git a/docs/audit/preuve-2026-08-23.md b/docs/audit/preuve-2026-08-23.md new file mode 100644 index 0000000..cbd3c6e --- /dev/null +++ b/docs/audit/preuve-2026-08-23.md @@ -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._ diff --git a/docs/catalogue-services.md b/docs/catalogue-services.md index 7dd72e4..5435748 100644 --- a/docs/catalogue-services.md +++ b/docs/catalogue-services.md @@ -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 | | 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) | +| 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 | *Rôles retirés (2026-07-04, supersédés ou hors conception)* : `serveur_sendmail` diff --git a/docs/couches-deploiement.yml b/docs/couches-deploiement.yml index 9b5d2fd..33b3030 100644 --- a/docs/couches-deploiement.yml +++ b/docs/couches-deploiement.yml @@ -61,6 +61,9 @@ couches: - serveur_nextcloud - serveur_web_frontal - 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 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." diff --git a/docs/dependances-groupes.yml b/docs/dependances-groupes.yml index b8762cc..f9d3cd3 100644 --- a/docs/dependances-groupes.yml +++ b/docs/dependances-groupes.yml @@ -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." 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: requiert_groupes_actifs: - serveur_postgresql diff --git a/exemples/modeles/socle/inventories/production/group_vars/all/10-intrants.yml b/exemples/modeles/socle/inventories/production/group_vars/all/10-intrants.yml index 9467f26..de22811 100644 --- a/exemples/modeles/socle/inventories/production/group_vars/all/10-intrants.yml +++ b/exemples/modeles/socle/inventories/production/group_vars/all/10-intrants.yml @@ -16,4 +16,19 @@ domaine_interne: "exemple.internal" fuseau_horaire: "America/Toronto" organisation: "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" diff --git a/exemples/modeles/socle/inventories/production/group_vars/serveur_nginx.yml b/exemples/modeles/socle/inventories/production/group_vars/serveur_nginx.yml new file mode 100644 index 0000000..9f77662 --- /dev/null +++ b/exemples/modeles/socle/inventories/production/group_vars/serveur_nginx.yml @@ -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 diff --git a/playbooks/groupes/serveur_ops.yml b/playbooks/groupes/serveur_ops.yml new file mode 100644 index 0000000..a769349 --- /dev/null +++ b/playbooks/groupes/serveur_ops.yml @@ -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 diff --git a/roles/serveur_ops/README.md b/roles/serveur_ops/README.md new file mode 100644 index 0000000..0613bd6 --- /dev/null +++ b/roles/serveur_ops/README.md @@ -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-/ le plan ← cloné depuis genome/ops- + 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./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.` | 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. diff --git a/roles/serveur_ops/defaults/main.yml b/roles/serveur_ops/defaults/main.yml new file mode 100644 index 0000000..1b9e2aa --- /dev/null +++ b/roles/serveur_ops/defaults/main.yml @@ -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" diff --git a/roles/serveur_ops/meta/authentification.yml b/roles/serveur_ops/meta/authentification.yml new file mode 100644 index 0000000..b77332e --- /dev/null +++ b/roles/serveur_ops/meta/authentification.yml @@ -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. diff --git a/roles/serveur_ops/meta/empreinte.yml b/roles/serveur_ops/meta/empreinte.yml new file mode 100644 index 0000000..06fed07 --- /dev/null +++ b/roles/serveur_ops/meta/empreinte.yml @@ -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 diff --git a/roles/serveur_ops/meta/flux.yml b/roles/serveur_ops/meta/flux.yml new file mode 100644 index 0000000..672ed2b --- /dev/null +++ b/roles/serveur_ops/meta/flux.yml @@ -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." diff --git a/roles/serveur_ops/tasks/main.yml b/roles/serveur_ops/tasks/main.yml new file mode 100644 index 0000000..404189a --- /dev/null +++ b/roles/serveur_ops/tasks/main.yml @@ -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" diff --git a/roles/serveur_ops/templates/LISEZ-MOI.md.j2 b/roles/serveur_ops/templates/LISEZ-MOI.md.j2 new file mode 100644 index 0000000..ed806b4 --- /dev/null +++ b/roles/serveur_ops/templates/LISEZ-MOI.md.j2 @@ -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 +``` diff --git a/scripts/prouver.py b/scripts/prouver.py index da67bc7..d1aeec5 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -829,6 +829,75 @@ def preuve_resolution_unique() -> tuple[bool, str]: 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]: """L'ecosysteme sait de QUOI il descend, et cette filiation tient encore. @@ -1195,6 +1264,8 @@ PREUVES: list[dict] = [ "func": preuve_parente_inscrite}, {"id": "P41", "titre": "Resolution d'instance : une seule, partagee", "refs": [], "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": [], "cmds": [[sys.executable, "scripts/verifier_ports.py"]]}, ]