sauvegarde : le site protege enfin son propre etat
Some checks are pending
verifier / verifier (push) Waiting to run

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
This commit is contained in:
Daniel Allaire 2026-09-02 11:21:52 -04:00
parent 53c5f41a1a
commit cf9abe74b6
4 changed files with 119 additions and 12 deletions

View file

@ -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

View file

@ -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. |

View file

@ -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]:

View file

@ -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