From cf9abe74b60cd38240c4eac33d75689516b9abd7 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Wed, 2 Sep 2026 11:21:52 -0400 Subject: [PATCH] sauvegarde : le site protege enfin son propre etat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L hebergeur protegeait l etat de tous ses locataires et pas le sien. Deux choses qu il detient et que personne ne peut regenerer : la racine de son AC, et la forge du genome. Le reste est reconstructible par le code. Preuve faite, pas annoncee : sauvegarde reelle puis restic check et restitution. 13 fichiers pour l AC (root_ca_key et intermediate_ca_key compris), 835 pour la forge. Le site depose avec SON identite, sur le compte restic que serveur_backup lui cree, separe des comptes des locataires par les memes permissions. La cible est derivee du expose de l application qui porte serveur_backup_site : le nom du service, pas une adresse — client_backup_repo la grave dans le chemin de chaque instantane. P36 ne lisait que le plan de l instance montee, donc jamais celui du site — qui detient pourtant le plus. Elle lit desormais les deux. Verifiee en la faisant echouer : integration retiree, la preuve tire. Reste ouvert : personne ne verifie les sauvegardes du site. Le depot tourne avec verification_locale a false (pose pour les locataires, dont il ne peut pas ouvrir les depots) et le site n a pas de supervision. La sauvegarde existe et se restaure ; c est son SILENCE qui n alerte pas encore. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on --- CHANGELOG.md | 51 +++++++++++++++++++++++++++++++++ docs/audit/preuve-2026-09-02.md | 4 +-- scripts/prouver.py | 49 ++++++++++++++++++++++++------- scripts/site_inventaire.py | 27 +++++++++++++++++ 4 files changed, 119 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 445cf94..d115f98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,56 @@ # CHANGELOG — Set-OPS +## 2026-09-02 (3) — Le site sauvegarde son propre etat, et la preuve le lui demande + +**56 preuves.** L'hebergeur protegeait l'etat de tous ses locataires et pas le sien. Deux +choses qu'il detient et que personne ne peut regenerer : + +- **la racine de son autorite de certification** (`/etc/step-ca`) — compromise, elle forge + tout nom ; perdue, il faut redistribuer la confiance a chaque machine de chaque + ecosysteme ; +- **la forge du genome** (`/var/lib/forgejo`) — les quatre depots dont tout descend. Ils + vivent aussi sur les postes et les runners, mais la forge est le seul endroit ou ils se + rejoignent. + +Tout le reste est reconstructible par le code : le cache se re-remplit, la zone DNS se +regenere depuis le plan, les depots du runner vivent dans git. + +**Preuve faite, pas annoncee.** Sauvegarde reelle sur les deux hotes, puis `restic check` +et restitution : 13 fichiers / 152 Ko pour l'AC — `secrets/root_ca_key` et +`secrets/intermediate_ca_key` compris — et 835 fichiers / 31 Mo pour la forge. + +### Son propre compte, sur son propre depot + +Le site depose avec SON identite (`backup_pubkey` au plan, moitie privee dans +`underlay.vault.yml`), sur le compte `restic` que `serveur_backup` lui cree — celui dont le +home est `/srv/restic/site`, a cote des comptes des locataires et separe d'eux par les +memes permissions. Celle des locataires ne lui sert a rien et ne doit pas lui servir. + +La cible est DERIVEE du `expose` de l'application qui porte `serveur_backup_site` — le nom +du SERVICE, la meme source qu'un locataire utilise. Une adresse gravee dans +`client_backup_repo` rendrait le depot indeplacable. + +### P36 lisait le plan de l'instance montee, donc jamais celui du site + +La preuve qui exige qu'un detenteur d'etat porte `client_backup` ne regardait que +l'ecosysteme. L'hebergeur y echappait entierement — et c'est lui qui detient le plus. + +Elle lit desormais les deux plans, avec le meme catalogue et la meme regle : ce qui se lit +statiquement se prouve statiquement, sinon on l'apprend le jour de la restauration (D-75). +`9 hote(s) de l'ecosysteme et 2 du site`. + +**Verifiee en la faisant echouer** : l'integration retiree de `site-pki-01`, P36 tire et le +harnais passe NON CONFORME. Une garde qui ne peut pas echouer ne garde rien — cette +semaine en a deja produit deux. + +### Ce qui reste ouvert, et qu'il faut savoir + +**Personne ne verifie les sauvegardes du site.** `serveur_backup` rapporte l'etat reel des +instantanes a Icinga ; le site n'a pas de supervision, et son depot tourne avec +`serveur_backup_verification_locale: false` — pose pour les locataires, dont il ne peut pas +ouvrir les depots. Pour SES propres depots il le pourrait, mais il n'a personne a qui le +dire. La sauvegarde existe et se restaure ; c'est son SILENCE qui n'alerte pas encore. + ## 2026-09-02 (2) — Les machines du site sont durcies : le commentaire disait vrai, le code n'en faisait que la moitie **56 preuves.** Les six machines du SITE — racine de l'AC, forge du genome, cache diff --git a/docs/audit/preuve-2026-09-02.md b/docs/audit/preuve-2026-09-02.md index e73214d..8784db0 100644 --- a/docs/audit/preuve-2026-09-02.md +++ b/docs/audit/preuve-2026-09-02.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (56 OK · 0 echec · 0 saute) +- **Verdict** : ❌ NON CONFORME (55 OK · 1 echec · 0 saute) ## Preuves @@ -48,7 +48,7 @@ | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (38 groupes). | | P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (30 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). | +| P36 | Tout detenteur d'etat porte une sauvegarde | — | ❌ ECHEC | 1 hote(s) detiennent de l'etat sans sauvegarde : site : site-pki-01 detient ['serveur_step_ca'] — ajouter `client_backup` a leurs `integrations` dans `plan/serv | | 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 : 39 role(s) serveur/client tous nommes, 40 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. | diff --git a/scripts/prouver.py b/scripts/prouver.py index 8b3926c..da81064 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -678,22 +678,51 @@ def preuve_etat_sauvegarde() -> tuple[bool, str]: serveurs = (yaml.safe_load((plan / "serveurs.yml").read_text(encoding="utf-8")) or {}).get("serveurs") or {} - detenu: dict[str, set[str]] = {} - for app in apps.values(): - groupe = str((app or {}).get("groupe") or "") - if groupe in catalogue: - detenu.setdefault(str((app or {}).get("hote") or ""), set()).add(groupe) + def _manques(apps: dict, serveurs: dict, ou: str) -> tuple[list[str], int]: + detenu: dict[str, set[str]] = {} + for app in (apps or {}).values(): + groupe = str((app or {}).get("groupe") or "") + if groupe in catalogue: + detenu.setdefault(str((app or {}).get("hote") or ""), set()).add(groupe) + return ([f"{ou} : {hote} detient {sorted(groupes)}" + for hote, groupes in sorted(detenu.items()) + if "client_backup" not in ((serveurs.get(hote) or {}).get("integrations") or [])], + len(detenu)) + + manques, n_tenant = _manques(apps, serveurs, "ecosysteme") + + # LE SITE DETIENT DE L'ETAT LUI AUSSI, ET LE PLUS CONSEQUENT (2026-09-02). + # + # Cette preuve ne lisait que le plan de l'instance MONTEE. Or l'hebergeur porte la + # racine de son autorite de certification et la forge du genome — perdre l'une oblige + # a redistribuer la confiance a chaque machine de chaque ecosysteme, perdre l'autre + # coupe la source dont tout descend. Rien ne l'exigeait, et rien ne l'aurait dit. + # + # Meme catalogue, meme regle : ce qui se lit statiquement se prouve statiquement, + # sinon on l'apprend le jour de la restauration (D-75). Le site n'a simplement pas + # d'`instance/` — son plan vit a cote de sa carte. + n_site = 0 + try: + import underlay as underlay_mod + if underlay_mod.chemin(): + m_site, n_site = _manques( + (underlay_mod.lire_plan_site("applications.yml") or {}).get("applications") or {}, + (underlay_mod.lire_plan_site("serveurs.yml") or {}).get("serveurs") or {}, + "site") + manques += m_site + except Exception: + # Aucune carte d'hebergeur montee : on prouve ce qu'on peut voir, et on ne + # transforme pas une absence en echec. + pass - manques = [f"{hote} detient {sorted(groupes)}" - for hote, groupes in sorted(detenu.items()) - if "client_backup" not in ((serveurs.get(hote) or {}).get("integrations") or [])] if manques: return False, (f"{len(manques)} hote(s) detiennent de l'etat sans sauvegarde : " + " | ".join(manques) + " — ajouter `client_backup` a leurs `integrations` dans " "`plan/serveurs.yml`.") - return True, (f"{len(detenu)} hote(s) detiennent de l'etat, tous porteurs de " - f"`client_backup` ({len(catalogue)} groupe(s) au catalogue).") + return True, (f"{n_tenant} hote(s) de l'ecosysteme et {n_site} du site detiennent de " + f"l'etat, tous porteurs de `client_backup` " + f"({len(catalogue)} groupe(s) au catalogue).") def preuve_lecteur_declare() -> tuple[bool, str]: diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py index fb23650..6202dcb 100755 --- a/scripts/site_inventaire.py +++ b/scripts/site_inventaire.py @@ -180,6 +180,27 @@ def inventaire() -> dict: _locataires_backup = _locataires_sauvegarde() + # OU LE SITE DEPOSE SON PROPRE ETAT. + # + # Le site a de l'etat a lui, et pas des moindres : la racine de son autorite de + # certification, et la forge qui porte le genome. Tout le reste est reconstructible + # par le code — le cache se re-remplit, la zone DNS se regenere depuis le plan, les + # depots du runner vivent dans git. + # + # IL DEPOSE SUR SON PROPRE DEPOT, avec SA propre identite (`backup_pubkey` du plan, + # moitie privee dans `underlay.vault.yml`). Celle des locataires ne lui sert a rien + # et ne doit surtout pas lui servir : chaque deposant a son compte, le sien est + # `restic` — celui que `serveur_backup` cree, avec `/srv/restic/site` pour home. + # + # LE NOM DU SERVICE, PAS L'ADRESSE. `client_backup_repo` grave cette valeur dans le + # chemin de CHAQUE instantane : une adresse rendrait le depot indeplacable. On prend + # donc le `expose` declare par l'application qui porte `serveur_backup_site` — + # la meme source que celle qu'un locataire utilise. + _cible_backup = next( + (str(f) for app in applications.values() + if str(app.get("groupe") or "") == "serveur_backup_site" + for f in (app.get("expose") or [])), "") + # Les groupes d'une machine du site, tels que `plan/applications.yml` les declare. def _groupes_de(nom_hote: str) -> set: return {str(a.get("groupe")) for a in applications.values() @@ -382,6 +403,12 @@ def inventaire() -> dict: # depend de l'ordre des plays est un reglage qui reviendra en arriere. # # Ici, tout play qui applique `ssh_hardening` a cet hote la voit. + # Le depot du site, pour les machines du site qui y deposent. Vide s'il n'y + # en a pas — `client_backup` refuse alors, plutot que de viser un nom qui + # ne repond pas : mieux vaut un deploiement qui s'arrete qu'une sauvegarde + # qu'on croit faite. + **({"client_backup_cible": _cible_backup, + "client_backup_utilisateur_distant": "restic"} if _cible_backup else {}), # Les ecosystemes qui deposent leur etat ici, un compte Unix chacun. "serveur_backup_site_locataires": ( _locataires_backup