sauvegarde : l etat d un locataire quitte enfin sa propre flotte
Some checks are pending
verifier / verifier (push) Waiting to run

Chezlepro rangeait ses instantanes sur une VM DE SA PROPRE FLOTTE. Raser
l ecosysteme pour le reconstruire, c etait raser le filet avec.

Le site a son depot ; les neuf detenteurs d etat y deposent ; une
restitution est sortie (annuaire LDAP lisible, hors flotte).

Isolation par compte Unix, pas par convention : home 0700, cle exclusive,
racine partagee a root en 0711 (traversable, non listable). Les deux refus
constates. Le site heberge du chiffre : il ne peut ni lire ni ouvrir, d ou
la verification deplacee chez le locataire qui detient la cle.

Quatre defauts reveles par ce deuxieme usage :
- la racine des depots ne peut etre le home de personne (StrictModes rendait
  Permission denied publickey pour un refus de CHEMIN)
- la racine nie le TLD internal, et harden-below-nxdomain etendait ce non a
  toute la zone sans jamais interroger l autoritatif : aucun locataire ne
  pouvait nommer un service du site
- le gabarit transporte des fichiers de durcissement perimes, et les machines
  du site ne recoivent jamais ssh_hardening
- MaxStartups compte les connexions non authentifiees : un depot de site en
  voit la somme de ses locataires

Constat non corrige : les machines du site ne sont pas durcies.

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-01 22:16:20 -04:00
parent 4abf875e5f
commit 3ea0c95492
20 changed files with 715 additions and 122 deletions

View file

@ -1,5 +1,84 @@
# CHANGELOG — Set-OPS
## 2026-09-01 — Le depot de sauvegarde du SITE : l'etat d'un locataire quitte enfin sa propre flotte
**56 preuves.** Chezlepro rangeait ses instantanes sur `backup-01`, une VM DE SA PROPRE
FLOTTE. Une sauvegarde rangee dans ce qu'elle protege ne protege rien : raser
l'ecosysteme pour le reconstruire, c'etait raser le filet avec. La reconstruction ne
pouvait donc pas etre tentee — le blocage n'etait pas technique, il etait structurel.
Le site a desormais son propre depot (`site-backup-01`, VLAN 35), et les neuf detenteurs
d'etat de Chezlepro y deposent. **Une restitution est sortie** : l'annuaire LDAP est
revenu lisible, `dc=chezlepro,dc=internal`, depuis un depot hors de la flotte.
### L'isolation entre locataires est celle du noyau, pas une convention
`serveur_backup` a un compte et une cle : juste pour un depot d'ecosysteme, faux pour un
depot de site. Une cle partagee aurait donne a chaque locataire la lecture — et
l'effacement — des instantanes des autres.
Un compte Unix par locataire, home en 0700, sa cle et rien qu'elle (`exclusive: true` :
le site est autorite sur qui entre chez lui). La racine partagee est a root en **0711** :
traversable, non listable — un locataire ne peut pas apprendre QUI sont ses voisins. Les
deux refus ont ete constates, pas supposes.
Ce que le site voit : des octets. restic chiffre CHEZ LE CLIENT, avec un mot de passe qui
reste dans la voute du locataire. Le site ne peut ni lire, ni ouvrir, ni reconstituer —
c'est ce qui rend la mutualisation acceptable, la meme frontiere que pour les voutes.
D'ou `serveur_backup_verification_locale: false` : la verification n'est pas supprimee,
elle est DEPLACEE chez celui qui detient la cle et sait ce qu'il a envoye.
### Quatre defauts que seul un deuxieme usage pouvait reveler
**1. La racine des depots ne peut etre le home de personne.** `/srv/restic` en 0700 au
compte `restic` : sshd a refuse `restic-chez` par `StrictModes`, qui exige que chaque
repertoire menant au home appartienne a root ou a l'utilisateur. Le message rendu etait
`Permission denied (publickey)` — celui d'une mauvaise cle. La cle etait la bonne ; c'est
le CHEMIN qui etait refuse, et rien dans ce message ne pouvait le dire.
**2. La racine nie notre TLD, et Unbound etend ce non a tout ce qui est dessous.**
`internal.` n'existe pas dans la racine, qui rend un NXDOMAIN **signe** (drapeau `ad`).
`harden-below-nxdomain` tient ce non pour prouve et fabrique un NXDOMAIN pour tout nom
sous `internal.`, **sans jamais interroger la `stub-zone`**. La delegation etait correcte,
l'autoritatif repondait juste, et pas une requete ne lui parvenait.
Ce que ca coutait : **aucun locataire ne pouvait nommer un service du site**. Chacun
gravait donc des ADRESSES dans sa configuration — le cache, le resolveur, la forge — et le
site devenait indeplacable un intrant a la fois. Le defaut ne s'est pas vu parce que les
machines du site ont le plancher `/etc/hosts` : le nom marchait partout ou on le testait,
et nulle part ou on en avait besoin. Remede : `local-zone transparent` sur notre zone —
le meme que pour les zones inverses, la meme cause.
**3. Le gabarit transporte des fichiers de durcissement perimes.** Dont
`20-chezlepro-hardening.conf`, celui-la meme que `ssh_hardening_fichiers_perimes` existe
pour retirer : fige dans l'image, donc herite par chaque VM clonee. Et **les machines du
SITE ne recoivent jamais `ssh_hardening`** — leur socle est `serveur_debian` seul, la ou
un locataire recoit aussi `serveur_durci`. Rien ne venait donc jamais le corriger.
**4. `MaxStartups` compte les connexions NON AUTHENTIFIEES.** Un depot d'ecosysteme en
voit une poignee ; celui d'un SITE en voit la somme de tous ses locataires, dont les
minuteurs se reveillent a la meme heure. Au-dela du seuil, sshd coupe AVANT la banniere :
le client rapporte `kex_exchange_identification: Connection reset by peer`, qui accuse le
reseau pour un refus applicatif. Douze sauvegardes simultanees, une perdue. Portee a
`60:30:200` sur le depot ; les douze repassent ensemble.
### Une patte de plus a la frontiere, et ce qu'elle a appris
Ajouter un segment au site n'avait aucune procedure ecrite. Il a fallu, dans l'ordre : le
declarer dans `underlay.yml`, creer le VLAN et l'interface sur OPNsense, ajouter le VLAN
au trunk du commutateur, une route sur le poste — et **inscrire la nouvelle patte dans
`opnsense_if_zones`**. Sans cette derniere ligne, le devis posait les regles du site sur
une patte ou son trafic ne passe pas : 16 regles creees, aucune ne correspondant jamais,
et rien ne le signalant.
### Constat non corrige, a decider
**Les machines du site ne sont pas durcies.** Pas d'auditd, pas de fail2ban, pas de
`nftables_baseline`, pas de `sysctl_hardening`, pas d'`unattended_upgrades` — alors
qu'elles portent la racine de l'AC, la forge du genome, et desormais les sauvegardes de
tous les locataires. `ssh_hardening` a ete pose sur le depot seul, la ou il bloquait.
Etendre `serveur_durci` a tout le site est un changement a mener pour lui-meme.
## 2026-09-01 — `q35` n'est pas un reglage : c'est la raison de la procedure manuelle
**56 preuves.** L'exploitant a explique pourquoi Set-OPS n'utilise pas l'image cloud

View file

@ -20,8 +20,8 @@
| 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 : 39 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 37 rôles, 97 flux, schéma + matrice OK. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 40 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 38 rôles, 98 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 | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
@ -35,34 +35,34 @@
| 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 : 11 reseau(x), aucune collision avec la plage tenant. |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 12 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 51 groupe(s), 81 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 15 hote(s) x 5 integration(s) universelle(s) : aucune lacune, aucune recopie (0 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, 35 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 31 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 20, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 32 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 21, 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 | 54 scripts expliques et atteignables, 108 cibles make documentees, 63 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 41 exigence(s) de role, toutes satisfaites (128 cle(s) declaree(s) par l'instance). |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 54 scripts expliques et atteignables, 108 cibles make documentees, 64 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 41 exigence(s) de role, toutes satisfaites (130 cle(s) declaree(s) par l'instance). |
| 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 (29 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 : 38 role(s) serveur/client tous nommes, 39 groupe(s) cite(s) en table existent tous. |
| 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. |
| 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 : 50 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 |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 5 machine(s) du plan retrouvees, 66 regle(s) du site. |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 6 machine(s) du plan retrouvees, 80 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 84 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (115 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 131 regles `pass`), tous non consignes et tous motives. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (116 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 145 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |

View file

@ -23,8 +23,8 @@ README de rôles). Cette page comble ces deux trous.
| Ce qu'on compte | Combien | Comment on le mesure |
|---|---|---|
| rôles | 63 | `roles/*/` |
| README de rôles | 63 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| rôles | 64 | `roles/*/` |
| README de rôles | 64 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 38 | `docs/*.md` |
| pièces d'audit | 32 | `docs/audit/*` |
| unités de wiki | 27 | `wiki/*.md` |

View file

@ -57,6 +57,7 @@ Ce que la reconstruction couvre, par capacité :
| Cache du site | `serveur_cache_site` | designe LE cache que les ecosystemes voisins prennent comme amont : Debian telecharge une fois pour toute la fabric, et le cache ne voit que des requetes agregees. Reserve a l'ecosysteme de l'hebergeur |
| Forge du genome du site | `serveur_forge_site` | designe LA forge dont les ecosystemes de ce site se reproduisent (D-81), et l'ouvre a eux. Marqueur : `serveur_forgejo` installe. |
| Resolveur du site | `serveur_resolveur_site` | designe LE resolveur que les ecosystemes de ce site interrogent tant qu'ils n'ont pas le leur. Marqueur : `serveur_resolveur` installe. |
| Depot de sauvegarde du site | `serveur_backup_site` | designe LE depot ou les ecosystemes de ce site posent leur etat tant qu'ils n'ont pas le leur. Marqueur : `serveur_backup` installe. |
| 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`

View file

@ -64,6 +64,8 @@ couches:
- serveur_forge_site
# Le resolveur du site, meme nature : prete aux locataires pendant leur jeunesse.
- serveur_resolveur_site
# Le depot de sauvegarde du site, meme nature : il recoit l'etat des locataires.
- serveur_backup_site
- nom: apps
raison: "Les applications métier, qui consomment les services (base, SSO, courriel, edge)."
groupes:

View file

@ -19,6 +19,7 @@
| `serveur_artefacts` | egress | 3142 | tcp | voisins_site | clair | Prendre le cache du site comme amont, plutot que d'aller chez Debian. |
| `serveur_backup` | ingress | 22 | tcp | client_backup | ssh | Dépôt restic servi par SSH (utilisateur restreint restic + clé) ; chaque client_backup pousse ses instantanés. |
| `serveur_backup` | egress | 5665 | tcp | serveur_icinga | tls-requis | Rapport passif des sauvegardes vers l'API Icinga : le depot est le seul a voir ce qui est reellement arrive. |
| `serveur_backup_site` | ingress | 22 | tcp | voisins_site | ssh | Les écosystèmes de ce site déposent leur état ici tant qu'ils n'ont pas leur propre dépôt. SFTP par un utilisateur restreint, jamais un compte d'administration : le site reçoit des octets chiffrés par restic, il ne peut pas les lire. |
| `serveur_cache_site` | ingress | 3142 | tcp | voisins_site | clair | Servir les caches des écosystèmes voisins. Debian n'est ainsi téléchargé qu'une fois pour tout le site, et le cache ne voit que des requêtes AGRÉGÉES — jamais quelle machine installe quoi. |
| `serveur_cache_site` | egress | 80 | tcp | externe | clair | Remplir le cache depuis les dépôts Debian amont. En clair parce que les dépôts apt sont signés : l'intégrité vient de la signature, pas du transport. |
| `serveur_cache_site` | egress | 443 | tcp | externe | tls-requis | Les dépôts tiers qui n'existent qu'en HTTPS (smallstep, Grafana, Icinga). Le cache les relaie pour que la flotte n'ait pas à sortir elle-même. |
@ -108,7 +109,7 @@
- **clair** : 37 flux
- **n-a** : 3 flux
- **ssh** : 7 flux
- **ssh** : 8 flux
- **starttls** : 6 flux
- **tls** : 9 flux
- **tls-cible** : 2 flux

View file

@ -0,0 +1,15 @@
---
- name: Appliquer le groupe serveur_backup_site
hosts: serveur_backup_site
become: true
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_backup_site

View file

@ -46,6 +46,8 @@
import_playbook: groupes/serveur_forge_site.yml
- name: Couche services — groupe serveur_resolveur_site
import_playbook: groupes/serveur_resolveur_site.yml
- name: Couche services — groupe serveur_backup_site
import_playbook: groupes/serveur_backup_site.yml
- name: Couche services — groupe serveur_resolveur
import_playbook: groupes/serveur_resolveur.yml
- name: Couche services — groupe serveur_dovecot

View file

@ -6,6 +6,20 @@ serveur_backup_racine: "/srv/restic"
# Clé PUBLIQUE de sauvegarde autorisée (la privée est dans la voûte, côté client_backup).
serveur_backup_pubkey: ""
# CE DÉPÔT PEUT-IL OUVRIR CE QU'IL GARDE ?
#
# `true` chez un écosystème : il garde SES dépôts, il en détient le mot de passe, il
# peut donc constater ce qui est réellement arrivé — et c'est le seul à le pouvoir,
# puisqu'un nœud sait qu'il a lancé sa sauvegarde sans savoir qu'elle a abouti.
#
# `false` chez le dépôt d'un site : restic chiffre CHEZ LE CLIENT, avec un mot de passe
# qui reste dans la voûte du locataire. Le site héberge des octets qu'il ne peut ni
# lire ni ouvrir. Ce n'est pas une lacune : c'est ce qui rend l'hébergement mutualisé
# acceptable. La vérification a alors lieu chez le locataire, qui détient la clé.
#
# `serveur_backup_site` met cette valeur à `false` et dit pourquoi.
serveur_backup_verification_locale: true
# --- Vérification des dépôts et rapport passif vers Icinga ---
# Le dépôt est le SEUL à voir ce qui est réellement arrivé. Un nœud sait qu'il a lancé sa
# sauvegarde ; il ne sait pas qu'elle a abouti. D'où la vérification ici, et pas là-bas.

View file

@ -27,114 +27,18 @@
key: "{{ serveur_backup_pubkey }}"
state: present
# --- Vérification des dépôts et rapport passif vers Icinga ---
# La liste des détenteurs d'état appartient à `client_backup`. On la LIT chez lui : deux
# listes finissent toujours par diverger, et la divergence se lirait « tout va bien ».
- name: Lire la liste des détenteurs d'état chez client_backup
ansible.builtin.include_vars:
file: "{{ role_path }}/../client_backup/vars/main.yml"
name: _catalogue_sauvegarde
- name: Adopter la liste des détenteurs d'état
ansible.builtin.set_fact:
client_backup_groupes_etat: "{{ _catalogue_sauvegarde.client_backup_groupes_etat }}"
- name: Exiger de quoi rapporter à Icinga (Vault + hôte de supervision)
ansible.builtin.assert:
that:
- serveur_backup_icinga_motdepasse | length > 0
- serveur_backup_icinga_hote | length > 0
- serveur_backup_noeuds_attendus | length > 0
fail_msg: >-
vault_icinga_api_depot requis, un hôte du groupe `serveur_icinga` doit exister, et
au moins un nœud doit détenir de l'état. Sans cela, les sauvegardes ne seraient
surveillées par personne — c'est exactement le silence qui a laissé le défaut du
2026-07-03 vivre un mois.
- name: Installer curl et le mot de passe restic pour la vérification
ansible.builtin.apt:
name: [curl, restic]
state: present
- name: Créer le répertoire des secrets Set-OPS
ansible.builtin.file:
path: /etc/setops
state: directory
owner: root
group: root
mode: "0700"
- name: Déposer le mot de passe restic (lecture des dépôts)
ansible.builtin.copy:
content: "{{ vault_restic_password }}\n"
dest: /etc/setops/restic.pass
owner: root
group: root
mode: "0600"
no_log: true
- name: Déposer le mot de passe d'API Icinga
ansible.builtin.copy:
content: "{{ serveur_backup_icinga_motdepasse }}"
dest: /etc/setops/icinga-api.pass
owner: root
group: root
mode: "0600"
no_log: true
# L'AC d'Icinga sert a VERIFIER le pair en rapportant (il refuse de servir un certificat
# qu'il n'a pas emis). Mais le depot est un SERVICE et la supervision une APPLICATION :
# la couche `services` precede `apps`, donc Icinga n'existe pas encore au premier
# deploiement. Exiger son AC ici inversait le graphe — P08 l'a refuse.
# LA VÉRIFICATION SUPPOSE DE POUVOIR OUVRIR LES DÉPÔTS — CE QUI N'EST PAS TOUJOURS VRAI.
#
# On la prend donc SI ELLE EXISTE, et c'est `serveur_icinga` qui la POUSSE quand il monte
# (roles/serveur_icinga/tasks/main.yml). Le rapporteur echoue bruyamment tant qu'elle
# manque — et il n'y a de toute facon personne a qui rapporter.
- name: L'AC d'Icinga est-elle deja disponible ?
ansible.builtin.stat:
path: "{{ serveur_backup_icinga_ca_source }}"
delegate_to: "{{ serveur_backup_icinga_hote }}"
register: serveur_backup_ca_presente
- name: Récupérer l'AC d'Icinga depuis l'hôte de supervision
when: serveur_backup_ca_presente.stat.exists
ansible.builtin.slurp:
src: "{{ serveur_backup_icinga_ca_source }}"
delegate_to: "{{ serveur_backup_icinga_hote }}"
register: serveur_backup_ca_icinga
- name: Déposer l'AC d'Icinga pour la vérification du pair
when: serveur_backup_ca_presente.stat.exists
ansible.builtin.copy:
content: "{{ serveur_backup_ca_icinga.content | b64decode }}"
dest: "{{ serveur_backup_ca_verification }}"
owner: root
group: root
mode: "0644"
- name: Déployer le script de vérification
ansible.builtin.template:
src: verifier-sauvegardes.sh.j2
dest: /usr/local/sbin/setops-verifier-sauvegardes.sh
owner: root
group: root
mode: "0700"
- name: Déployer l'unité et le timer de vérification
ansible.builtin.template:
src: "{{ item.s }}"
dest: "/etc/systemd/system/{{ item.d }}"
owner: root
group: root
mode: "0644"
loop:
- { s: setops-verification.service.j2, d: setops-verification.service }
- { s: setops-verification.timer.j2, d: setops-verification.timer }
- name: Activer le timer de vérification
when: not ansible_check_mode
ansible.builtin.systemd:
name: setops-verification.timer
enabled: true
state: started
daemon_reload: true
# Ce qui suit interroge restic, donc déchiffre. Un dépôt ne peut le faire que s'il
# détient le mot de passe des dépôts qu'il garde — vrai chez un écosystème, qui garde
# les siens ; FAUX chez le dépôt d'un site, qui garde ceux de ses locataires et ne
# détient aucun de leurs mots de passe. Il héberge du chiffré : c'est la propriété qui
# rend l'hébergement acceptable, pas une lacune à combler.
#
# Là où le site ne peut pas vérifier, c'est le LOCATAIRE qui vérifie — il détient la
# clé, et il est seul à savoir ce qu'il a envoyé. La surveillance ne disparaît donc
# pas : elle reste chez celui qui peut réellement l'exercer.
- name: Vérification locale des dépôts (là où ce dépôt peut les ouvrir)
ansible.builtin.include_tasks: verification.yml
when: serveur_backup_verification_locale | bool

View file

@ -0,0 +1,112 @@
---
# --- Vérification des dépôts et rapport passif vers Icinga ---
# La liste des détenteurs d'état appartient à `client_backup`. On la LIT chez lui : deux
# listes finissent toujours par diverger, et la divergence se lirait « tout va bien ».
- name: Lire la liste des détenteurs d'état chez client_backup
ansible.builtin.include_vars:
file: "{{ role_path }}/../client_backup/vars/main.yml"
name: _catalogue_sauvegarde
- name: Adopter la liste des détenteurs d'état
ansible.builtin.set_fact:
client_backup_groupes_etat: "{{ _catalogue_sauvegarde.client_backup_groupes_etat }}"
- name: Exiger de quoi rapporter à Icinga (Vault + hôte de supervision)
ansible.builtin.assert:
that:
- serveur_backup_icinga_motdepasse | length > 0
- serveur_backup_icinga_hote | length > 0
- serveur_backup_noeuds_attendus | length > 0
fail_msg: >-
vault_icinga_api_depot requis, un hôte du groupe `serveur_icinga` doit exister, et
au moins un nœud doit détenir de l'état. Sans cela, les sauvegardes ne seraient
surveillées par personne — c'est exactement le silence qui a laissé le défaut du
2026-07-03 vivre un mois.
- name: Installer curl et le mot de passe restic pour la vérification
ansible.builtin.apt:
name: [curl, restic]
state: present
- name: Créer le répertoire des secrets Set-OPS
ansible.builtin.file:
path: /etc/setops
state: directory
owner: root
group: root
mode: "0700"
- name: Déposer le mot de passe restic (lecture des dépôts)
ansible.builtin.copy:
content: "{{ vault_restic_password }}\n"
dest: /etc/setops/restic.pass
owner: root
group: root
mode: "0600"
no_log: true
- name: Déposer le mot de passe d'API Icinga
ansible.builtin.copy:
content: "{{ serveur_backup_icinga_motdepasse }}"
dest: /etc/setops/icinga-api.pass
owner: root
group: root
mode: "0600"
no_log: true
# L'AC d'Icinga sert a VERIFIER le pair en rapportant (il refuse de servir un certificat
# qu'il n'a pas emis). Mais le depot est un SERVICE et la supervision une APPLICATION :
# la couche `services` precede `apps`, donc Icinga n'existe pas encore au premier
# deploiement. Exiger son AC ici inversait le graphe — P08 l'a refuse.
#
# On la prend donc SI ELLE EXISTE, et c'est `serveur_icinga` qui la POUSSE quand il monte
# (roles/serveur_icinga/tasks/main.yml). Le rapporteur echoue bruyamment tant qu'elle
# manque — et il n'y a de toute facon personne a qui rapporter.
- name: L'AC d'Icinga est-elle deja disponible ?
ansible.builtin.stat:
path: "{{ serveur_backup_icinga_ca_source }}"
delegate_to: "{{ serveur_backup_icinga_hote }}"
register: serveur_backup_ca_presente
- name: Récupérer l'AC d'Icinga depuis l'hôte de supervision
when: serveur_backup_ca_presente.stat.exists
ansible.builtin.slurp:
src: "{{ serveur_backup_icinga_ca_source }}"
delegate_to: "{{ serveur_backup_icinga_hote }}"
register: serveur_backup_ca_icinga
- name: Déposer l'AC d'Icinga pour la vérification du pair
when: serveur_backup_ca_presente.stat.exists
ansible.builtin.copy:
content: "{{ serveur_backup_ca_icinga.content | b64decode }}"
dest: "{{ serveur_backup_ca_verification }}"
owner: root
group: root
mode: "0644"
- name: Déployer le script de vérification
ansible.builtin.template:
src: verifier-sauvegardes.sh.j2
dest: /usr/local/sbin/setops-verifier-sauvegardes.sh
owner: root
group: root
mode: "0700"
- name: Déployer l'unité et le timer de vérification
ansible.builtin.template:
src: "{{ item.s }}"
dest: "/etc/systemd/system/{{ item.d }}"
owner: root
group: root
mode: "0644"
loop:
- { s: setops-verification.service.j2, d: setops-verification.service }
- { s: setops-verification.timer.j2, d: setops-verification.timer }
- name: Activer le timer de vérification
when: not ansible_check_mode
ansible.builtin.systemd:
name: setops-verification.timer
enabled: true
state: started
daemon_reload: true

View file

@ -0,0 +1,49 @@
# serveur_backup_site
**Le marqueur du dépôt de sauvegarde du site.** Il dit que *ce* dépôt est celui où les
écosystèmes du site déposent leur état, et déclare le flux qui le permet. Il n'installe
rien.
## Le quatrième service prêté
| ce que le site prête | pendant la jeunesse du tenant |
|---|---|
| les paquets | `serveur_cache_site` |
| le génome | `serveur_forge_site` |
| les noms | `serveur_resolveur_site` |
| **l'état** | **ce rôle** |
## Ce qu'il a corrigé
Mesuré le 2026-09-01, en préparant un rasage de Chezlepro : **toutes ses sauvegardes
vivaient sur `backup-01`, une VM de sa propre flotte.** Raser l'écosystème aurait détruit
les données et leur seul filet dans le même geste.
La doctrine affirmait pourtant que `client_backup_cible` *« pointe déjà hors de
l'écosystème »*. C'était vrai pour patient 0, qui sauvegarde chez `eregion` — faux pour
Chezlepro. *Un document qui décrit une propriété que le réel n'a pas est exactement ce que
ce dépôt traque.*
## Pourquoi une zone à lui seul
`site-sauvegarde` (VLAN 35) n'existe que pour lui. Il détient l'état de **chaque** tenant :
clés d'autorité, annuaires, bases, courriel. C'est la machine dont la compromission coûte
le plus cher du site — davantage que la forge, dont le contenu est du code, et davantage
que l'AC, qui forge des noms sans détenir de données.
Le mettre avec le génome aurait partagé un domaine de diffusion entre ce que les tenants
**viennent chercher** et ce qu'ils **déposent**. Deux natures opposées : l'une est faite
pour être lue par tous, l'autre pour n'être lue par personne.
## Ce qui rend la mutualisation acceptable
Le site reçoit des **octets chiffrés par restic avant de partir**. Il héberge l'état de
ses locataires sans pouvoir le lire — c'est ce qui distingue ce service de la voûte, qui
ne se mutualise à aucun âge.
## Ce que ce rôle ne fait pas
Il n'installe rien. Il vérifie deux choses : que la racine des dépôts **existe**, et qu'il
reste de la **place** devant les locataires. *Un dépôt qui se remplit n'échoue pas à
l'ouverture — il échoue au milieu d'un instantané, chez le locataire, et la panne se lit
comme une erreur réseau.*

View file

@ -0,0 +1,47 @@
---
# LE DÉPÔT DE SAUVEGARDE DU SITE — le marqueur, pas l'installateur.
#
# `serveur_backup` pose le dépôt et son utilisateur restreint ; ce rôle dit que CE dépôt
# est celui où les écosystèmes du site déposent leur état, et déclare le flux qui le
# permet.
#
# Les valeurs viennent de `serveur_backup` : on ne les redéclare pas ici, une seconde
# source finirait par diverger de la première.
# LA RACINE PARTAGÉE — parent des dépôts, home de personne.
#
# Chaque déposant (les locataires, et le site lui-même) a SON sous-répertoire en 0700.
# Celui-ci est leur parent commun : il appartient à root, et n'est ni le home ni la
# propriété d'aucun compte de dépôt. C'est ce que `sshd` exige (`StrictModes`) pour
# accepter une clé, et ce que le noyau exige pour qu'un compte puisse simplement
# atteindre son propre répertoire.
serveur_backup_site_parent: "/srv/restic"
# Le sous-répertoire du SITE lui-même. La même valeur est passée à `serveur_backup`
# dans `meta/main.yml` — un paramètre de dépendance n'est pas en portée ici, on ne peut
# donc pas la lire, seulement la répéter.
#
# Deux littéraux qui doivent s'accorder finissent toujours par diverger. Ce qui les
# tient, c'est la vérification plus bas : le marqueur EXIGE que ce répertoire existe
# réellement. S'ils divergent, l'installateur en aura créé un autre, celui-ci sera
# absent, et le déploiement s'arrêtera en le nommant — au lieu de laisser un dépôt de
# site silencieusement inaccessible.
serveur_backup_site_racine: "{{ serveur_backup_site_parent }}/site"
serveur_backup_site_utilisateur: "{{ serveur_backup_utilisateur | default('restic') }}"
# COMBIEN DE PLACE IL FAUT AVOIR DEVANT SOI, ET POURQUOI ON LE MESURE.
#
# Un dépôt de sauvegarde qui se remplit ne tombe pas : il ACCEPTE moins, puis échoue au
# milieu d'un instantané. La panne arrive donc au pire moment — celui où l'on écrit — et
# se lit comme une erreur réseau. On refuse en amont plutôt que de la découvrir en aval.
#
# 20 Go : de quoi absorber plusieurs instantanés complets d'un écosystème. Ce n'est pas
# une capacité cible, c'est un plancher sous lequel il ne faut pas descendre sans le voir.
serveur_backup_site_espace_minimal_go: 20
# LES LOCATAIRES — un compte Unix chacun, dérivé par l'inventaire du site.
#
# Chaque entrée : `{nom, compte, pubkey}`. La liste vient de `site_inventaire.py`, qui
# détient les deux sources nécessaires (le plan du site ET les dépôts des tenants) ; le
# rôle, lui, tourne sur une machine qui n'a aucune raison de lire la carte de
# l'hébergeur. Vide par défaut : un dépôt sans locataire déclaré n'ouvre aucun compte.
serveur_backup_site_locataires: []

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: cle-ssh
formulaire_local: sans-objet
secours: "Acces SSH a l'hote"
raison: >-
Les locataires deposent par SFTP, avec une CLE dediee et un utilisateur restreint —
jamais un compte d'administration. Et ce qu'ils deposent est chiffre par restic AVANT
de partir : le site heberge leur etat sans pouvoir le lire. C'est ce qui rend ce
service mutualisable alors que la voute ne l'est jamais.

View file

@ -0,0 +1,7 @@
---
# Empreinte ressources — ce role n'installe rien. Il attribue une responsabilite a un
# depot qui existe deja, et la rend lisible dans le plan.
setops_empreinte:
coeurs: 0
memoire_mo: 0
disque_go: 0

View file

@ -0,0 +1,29 @@
---
# Flux réseau du DÉPÔT DE SAUVEGARDE DU SITE. Voir docs/flux-conception.md.
#
# QUATRIÈME SERVICE QUE LE SITE PRÊTE À SES LOCATAIRES — après les paquets
# (`serveur_cache_site`), le génome (`serveur_forge_site`) et les noms
# (`serveur_resolveur_site`). Celui-ci porte leur ÉTAT.
#
# CE QUE SON ABSENCE COÛTAIT (mesuré le 2026-09-01). Chezlepro sauvegardait sur
# `backup-01`, une VM de sa PROPRE flotte. Raser l'écosystème aurait détruit les données
# et leur seul filet dans le même geste — et la doctrine affirmait pourtant que
# `client_backup_cible` « pointe déjà hors de l'écosystème ». C'était vrai pour patient 0,
# faux pour Chezlepro.
#
# `voisins_site` désigne les autres tenants que CE SITE héberge. Ni `flotte`, ni `externe` :
# un dépôt de sauvegarde ouvert à l'Internet serait la pire porte du site.
flux:
- sens: ingress
port: 22
protocole: tcp
pair: voisins_site
chiffrement: ssh
raison: >-
Les écosystèmes de ce site déposent leur état ici tant qu'ils n'ont pas leur propre
dépôt. SFTP par un utilisateur restreint, jamais un compte d'administration : le
site reçoit des octets chiffrés par restic, il ne peut pas les lire.
# CE RÔLE N'OUVRE AUCUNE ÉCOUTE — il emprunte celle de `serveur_debian`, comme
# `serveur_backup` le fait déjà. Sans ce mot, P33 y verrait trois rôles co-localisés
# se disputant le port 22, alors qu'il n'y a qu'un seul sshd.
partage: true

View file

@ -0,0 +1,75 @@
---
# CE RÔLE EN EXIGE UN AUTRE, ET C'EST À ANSIBLE DE LE SAVOIR.
#
# `serveur_backup_site` n'installe rien : il MARQUE un dépôt comme celui où les
# écosystèmes du site déposent leur état, et vérifie qu'il est réellement en mesure de le
# recevoir. Pour cela il lit les variables de `serveur_backup`.
#
# Les valeurs par défaut d'un rôle ne sont en portée que DANS LE PLAY QUI L'INCLUT. Une
# dépendance de rôle règle les deux choses d'un coup : Ansible applique l'installateur
# AVANT le marqueur, dans le MÊME play, donc ses variables sont en portée — et l'ordre
# cesse de dépendre de la façon dont on lance le déploiement.
#
# Même raisonnement que `serveur_cache_site`, `serveur_forge_site` et
# `serveur_resolveur_site`.
dependencies:
# EN PARAMÈTRE, PAS EN `defaults` — mesuré ici même le 2026-09-01.
#
# Une dépendance s'exécute AVANT le rôle qui la déclare, et les `defaults` du rôle
# dépendant ne sont pas encore en portée quand elle tourne. La valeur posée dans
# `serveur_backup_site/defaults` était donc lue par tout le monde SAUF par le rôle
# qu'elle visait. Passée ici, elle porte — c'est le seul endroit où elle porte.
#
# CE DÉPÔT NE PEUT PAS OUVRIR CE QU'IL GARDE, ET C'EST LE BUT. Les instantanés sont
# chiffrés par restic CHEZ LE LOCATAIRE, avec un mot de passe qui ne quitte jamais sa
# voûte. Le site héberge des octets opaques : il ne peut ni les lire, ni les ouvrir,
# ni les reconstituer. C'est la propriété qui rend la mutualisation acceptable — la
# même frontière que pour les voûtes : le site sert, il ne sait pas.
#
# La vérification n'est pas supprimée, elle est DÉPLACÉE chez celui qui peut
# l'exercer : le locataire, qui détient la clé et sait ce qu'il a envoyé.
- role: serveur_backup
serveur_backup_verification_locale: false
# LE SITE N'HABITE PAS LA RACINE DE SON PROPRE DÉPÔT.
#
# `serveur_backup` donne au compte `restic` la racine des dépôts pour home. C'est
# juste chez un écosystème : il n'y a qu'un déposant, la racine EST son home.
#
# Ici il y en a plusieurs, et la racine devient le PARENT de leurs homes. Elle ne
# peut donc appartenir à aucun d'eux — mesuré le 2026-09-01 : `/srv/restic` en 0700
# au compte `restic`, sshd a refusé `restic-chez` par `StrictModes`, qui exige que
# chaque répertoire menant au home appartienne à root ou à l'utilisateur lui-même.
# Le message rendu était « Permission denied (publickey) » — celui d'une mauvaise
# clé. La clé était la bonne ; c'est le CHEMIN qui était refusé. Rien dans ce
# message ne pouvait le dire.
#
# Le site prend donc un sous-répertoire comme tout le monde. Le parent est à root,
# traversable et non listable : voir `serveur_backup_site_parent`.
serveur_backup_racine: "/srv/restic/site"
# SSH DURCI, ET DESSERRÉ LÀ OÙ CE DÉPÔT L'EXIGE (mesuré le 2026-09-01).
#
# DEUX DÉFAUTS EN UN, TROUVÉS EN LANÇANT DOUZE SAUVEGARDES D'UN COUP.
#
# 1. Le gabarit transporte des fichiers de durcissement PÉRIMÉS — dont
# `20-chezlepro-hardening.conf`, celui-là même que `ssh_hardening_fichiers_perimes`
# existe pour retirer. Il a été figé dans l'image, donc chaque VM clonée en hérite.
# Les machines du SITE ne recevaient jamais `ssh_hardening` (leur socle est
# `serveur_debian` seul, là où un locataire reçoit aussi `serveur_durci`) : rien ne
# venait donc jamais le corriger. Le dépôt tournait avec `MaxStartups 5:30:20` et
# `MaxSessions 2`, des valeurs abandonnées depuis le 2026-08-09.
#
# 2. `MaxStartups` compte les connexions NON ENCORE AUTHENTIFIÉES. Un dépôt
# d'écosystème en voit une poignée ; celui d'un SITE en voit la somme de tous ses
# locataires, et leurs minuteurs se réveillent à la même heure. Au-delà du seuil,
# sshd coupe AVANT la bannière : le client rapporte
# `kex_exchange_identification: Connection reset by peer`, qui accuse le réseau
# pour un refus applicatif. Une sauvegarde perdue qui se lit comme un incident
# réseau ne sera pas cherchée au bon endroit.
#
# 60 : large devant la somme des flottes servies. Le port 22 de ce dépôt n'est
# atteignable que depuis les locataires déclarés (`meta/flux.yml`) ; l'inondation dont
# `MaxStartups` protège est déjà couverte à la frontière, la panne qu'il créait ne
# l'était pas. Même arbitrage que `ssh_hardening` a fait pour la voie d'administration.
- role: ssh_hardening
ssh_hardening_max_startups: "60:30:200"

View file

@ -0,0 +1,168 @@
---
# CE RÔLE MARQUE UN DÉPÔT ; IL N'EN INSTALLE PAS.
#
# Il présuppose `serveur_backup` sur le même hôte et lit ses variables. Le message qu'on
# obtiendrait sans lui — « variable non définie » — envoie chercher une faute de frappe
# alors que la cause est une déclaration incomplète, un cran au-dessus.
- name: Le rôle qui installe le dépôt est-il bien là ?
ansible.builtin.assert:
that:
- serveur_backup_site_racine | length > 0
- serveur_backup_site_utilisateur | length > 0
fail_msg: >-
`serveur_backup_site` marque un dépôt existant, il n'en installe aucun.
Cet hôte doit AUSSI porter `serveur_backup`. Ajouter les deux rôles à sa
déclaration, dans cet ordre.
# ÉCRIRE, PUIS RELIRE (D-68). La seule prise de ce rôle sur le réel est de vérifier que le
# dépôt qu'il désigne peut RÉELLEMENT recevoir. Sans ça, il attribuerait la sauvegarde de
# tous les locataires à un répertoire qui n'existe pas, ou à un disque plein — et la panne
# arriverait au moment de l'écriture, chez le locataire, en ressemblant à autre chose.
- name: La racine des dépôts existe-t-elle vraiment ?
ansible.builtin.stat:
path: "{{ serveur_backup_site_racine }}"
register: serveur_backup_site_racine_etat
- name: Combien de place reste-t-il devant les locataires ?
ansible.builtin.command:
argv: ["df", "-BG", "--output=avail", "{{ serveur_backup_site_racine }}"]
register: serveur_backup_site_place
changed_when: false
failed_when: false
when: serveur_backup_site_racine_etat.stat.exists
- name: Exiger un dépôt qui peut recevoir
ansible.builtin.assert:
that:
- serveur_backup_site_racine_etat.stat.exists
- serveur_backup_site_racine_etat.stat.isdir
- serveur_backup_site_espace_libre_go | int >= serveur_backup_site_espace_minimal_go | int
fail_msg: >-
{{ serveur_backup_site_racine }}
{{ serveur_backup_site_racine_etat.stat.exists
| ternary('n a plus que ' ~ serveur_backup_site_espace_libre_go ~ ' Go libres (minimum '
~ serveur_backup_site_espace_minimal_go ~ ')', 'n existe pas') }}.
Un dépôt qui se remplit n'échoue pas à l'ouverture : il échoue AU MILIEU d'un
instantané, chez le locataire, et la panne se lit comme une erreur réseau.
success_msg: >-
Dépôt du site prêt : {{ serveur_backup_site_racine }},
{{ serveur_backup_site_espace_libre_go }} Go libres.
vars:
# `df --output=avail -BG` rend « 123G » en deuxieme ligne. On decoupe plutot que
# d'employer une expression a groupe : celles-ci rendent `None` quand rien ne
# correspond, et le gabarit explose au lieu de se taire — paye trois fois cette
# semaine.
serveur_backup_site_espace_libre_go: >-
{{ (serveur_backup_site_place.stdout_lines | default(['', '0G']) | last
| default('0G', true)) | trim | replace('G', '') | default('0', true) }}
when: not ansible_check_mode
# --- UN COMPTE PAR LOCATAIRE ---------------------------------------------------
#
# POURQUOI PAS UN SEUL COMPTE AVEC PLUSIEURS CLES.
#
# `serveur_backup` pose UN compte `restic` et UNE clé : c'est juste pour le dépôt d'un
# écosystème, où il n'y a qu'un déposant. Ici il y en a plusieurs, et la même clé pour
# tous donnerait à chaque locataire la lecture — et l'effacement — des instantanés de
# tous les autres. Une sauvegarde qui expose ce qu'elle protège ne protège rien ; une
# sauvegarde qu'un voisin peut effacer ne protège rien non plus.
#
# La séparation par répertoire seule ne suffit pas : elle tient tant que personne ne
# tape `cd ..`. Le compte Unix, lui, est une frontière que le noyau fait respecter.
#
# Ce que ça ne protège PAS, et qu'il faut savoir : le site voit les octets. Il ne peut
# pas les lire — restic chiffre CHEZ LE CLIENT, avec un mot de passe qui reste dans la
# voûte du locataire, que le site ne détient pas. Le site héberge du chiffré ; il ne
# peut ni le lire, ni le déchiffrer, ni le reconstituer. C'est la même frontière que
# pour les voûtes : le site sert, il ne sait pas.
# LA RACINE PARTAGÉE — À ROOT, TRAVERSABLE, NON LISTABLE.
#
# 0711 et non 0755 : personne n'a besoin d'ÉNUMÉRER les locataires. Un locataire
# traverse pour atteindre son propre répertoire, et ne peut pas apprendre qui d'autre
# dépose ici. La liste des voisins est déjà une information.
- name: La racine partagée n'appartient à aucun déposant
ansible.builtin.file:
path: "{{ serveur_backup_site_parent }}"
state: directory
owner: root
group: root
mode: "0711"
# Deux littéraux doivent s'accorder : le parent déclaré ici, et le `serveur_backup_racine`
# que `meta/main.yml` passe à l'installateur. S'ils divergent, le site déposerait hors de
# sa propre racine et plus rien ne le dirait.
- name: Le dépôt du site vit-il bien sous la racine partagée ?
ansible.builtin.assert:
that:
- serveur_backup_site_racine is match(serveur_backup_site_parent ~ "/.+")
fail_msg: >-
Le dépôt du site ({{ serveur_backup_site_racine }}) n'est pas sous la racine
partagée ({{ serveur_backup_site_parent }}). `meta/main.yml` et
`defaults/main.yml` ne s'accordent plus.
- name: Ouvrir un compte pour chaque locataire du dépôt
ansible.builtin.user:
name: "{{ item.compte }}"
home: "{{ serveur_backup_site_parent }}/{{ item.compte }}"
shell: /bin/bash
create_home: true
system: true
loop: "{{ serveur_backup_site_locataires }}"
loop_control:
label: "{{ item.nom }} -> {{ item.compte }}"
# 0700 : le home d'un locataire n'est lisible que par lui. Sans ça, le compte Unix
# séparerait l'écriture mais pas la lecture — la moitié du problème résolue se lirait
# comme le problème résolu.
- name: Refermer le dépôt de chaque locataire sur lui-même
ansible.builtin.file:
path: "{{ serveur_backup_site_parent }}/{{ item.compte }}"
state: directory
owner: "{{ item.compte }}"
group: "{{ item.compte }}"
mode: "0700"
loop: "{{ serveur_backup_site_locataires }}"
loop_control:
label: "{{ item.compte }}"
# `exclusive: true` : le site est AUTORITÉ sur qui entre chez lui. Une clé ajoutée à la
# main — par dépannage, par un ancien locataire, par erreur — disparaît au prochain
# passage. Sans ce drapeau, `authorized_key` n'ajoute jamais que des lignes : une clé
# retirée du plan resterait valide pour toujours, et rien ne le dirait.
- name: N'autoriser chez chaque locataire que SA clé
ansible.posix.authorized_key:
user: "{{ item.compte }}"
key: "{{ item.pubkey }}"
exclusive: true
state: present
loop: "{{ serveur_backup_site_locataires }}"
loop_control:
label: "{{ item.nom }}"
# ÉCRIRE, PUIS RELIRE (D-68). On vient de déclarer une isolation ; on la constate.
# Deux locataires qui partageraient un home, ou un home lisible par le groupe, se
# liraient dans le journal comme un succès.
- name: Relire ce que le noyau applique vraiment
ansible.builtin.stat:
path: "{{ serveur_backup_site_parent }}/{{ item.compte }}"
register: serveur_backup_site_homes
loop: "{{ serveur_backup_site_locataires }}"
loop_control:
label: "{{ item.compte }}"
- name: Exiger une isolation réelle entre locataires
ansible.builtin.assert:
that:
- item.stat.exists
- item.stat.pw_name == item.item.compte
- item.stat.mode == "0700"
fail_msg: >-
Le dépôt de {{ item.item.nom }} n'est pas isolé :
propriétaire « {{ item.stat.pw_name | default('?') }} »,
permissions « {{ item.stat.mode | default('?') }} » (attendu : {{ item.item.compte }}, 0700).
Tant que ce n'est pas vrai, un locataire peut lire — ou effacer — les instantanés
d'un autre.
loop: "{{ serveur_backup_site_homes.results | default([]) }}"
loop_control:
label: "{{ item.item.compte }}"
when: not ansible_check_mode

View file

@ -13,6 +13,27 @@ server:
do-ip6: no
# La zone souveraine n'est pas signée : on ne la soumet pas à la validation DNSSEC.
domain-insecure: "{{ serveur_resolveur_zone_interne }}"
# LA RACINE NIE NOTRE TLD, ET UNBOUND ÉTEND CE NON À TOUT CE QUI EST DESSOUS
# (mesuré le 2026-09-01).
#
# `internal.` n'existe pas dans la racine, qui rend donc un NXDOMAIN **signé** —
# `dig internal. SOA` le montre avec le drapeau `ad`. `harden-below-nxdomain`
# (actif par défaut) tient ce non pour prouvé et fabrique lui-même un NXDOMAIN pour
# tout nom sous `internal.`, SANS JAMAIS interroger la `stub-zone` déclarée plus
# bas. La délégation était correcte, l'autoritatif répondait juste, et pas une seule
# requête ne lui parvenait.
#
# CE QUE ÇA COÛTAIT : aucun locataire ne pouvait nommer un service du site. Chacun
# gravait donc des ADRESSES dans sa configuration — le cache, le résolveur, la
# forge — et le site devenait indéplaçable un intrant à la fois. Le défaut ne s'est
# pas vu parce que les machines du site, elles, ont le plancher `/etc/hosts` : le
# nom marchait partout où on le testait, et nulle part où on en avait besoin.
#
# `transparent` sur NOTRE zone seulement : la question reprend son cours normal —
# donc atteint la `stub-zone` — sans rien changer pour le reste de l'Internet.
# Même remède que pour les zones inverses ci-dessous, même cause : un « non »
# d'Unbound qu'on ne distingue pas d'une absence.
local-zone: "{{ serveur_resolveur_zone_interne }}." transparent
{% for zone_inverse in serveur_resolveur_zones_inverses | default([]) %}
# Les zones inverses ne le sont pas davantage — et `in-addr.arpa`, LUI, est signé
# publiquement. Sans cette ligne, la validation chercherait une chaîne de confiance

View file

@ -117,6 +117,52 @@ def inventaire() -> dict:
except Exception:
_supernets_tenants = []
# LES LOCATAIRES DU DEPOT DE SAUVEGARDE — UN COMPTE UNIX CHACUN.
#
# Le depot du site recoit l'etat de PLUSIEURS ecosystemes. `serveur_backup`, concu
# pour le depot d'un tenant, n'a qu'un compte et qu'une cle : la ou il n'y a qu'un
# locataire, ca suffit. Ici, ca ne suffit plus — une seule cle partagee donnerait a
# chaque tenant la lecture des instantanes de tous les autres. Une sauvegarde qui
# expose ce qu'elle protege ne protege rien.
#
# D'ou un compte par locataire, chacun avec SON home en 0700 et SA cle. L'isolation
# est alors celle du noyau, pas une convention de nommage de repertoires.
#
# La cle PUBLIQUE d'un tenant n'est pas un secret : elle vit deja en clair dans son
# `group_vars/serveur_backup.yml`. C'est la MOITIE PRIVEE qui reste chez lui, dans sa
# voute, que le site ne detient pas et n'a pas a detenir. Le site heberge des
# octets chiffres par restic cote client : il ne peut pas les lire non plus.
#
# Meme decouverte que ci-dessus (`decouvrir_du_site`) : le site sert les tenants
# qu'il heberge, et cesse de les servir quand ils s'emancipent.
def _locataires_sauvegarde() -> list:
out = []
try:
import devis_reseau
racine_instances = devis_reseau.DOSSIER_INSTANCES
decouverts = devis_reseau.decouvrir_du_site()
except Exception:
return out
for nom_depot, prefixe, _n in decouverts:
gv = (racine_instances / nom_depot / "inventories" / "principal"
/ "group_vars" / "serveur_backup.yml")
try:
cle = str((yaml.safe_load(gv.read_text(encoding="utf-8")) or {})
.get("serveur_backup_pubkey") or "").strip()
except OSError:
cle = ""
# Un locataire sans cle publique declaree n'est pas une erreur du site :
# c'est un tenant qui n'a pas encore de sauvegarde. On ne lui ouvre pas de
# compte, et surtout on n'en ouvre pas un SANS cle — il accepterait alors
# tout ce que le compte `restic` accepte deja.
if cle:
out.append({"nom": nom_depot,
"compte": "restic-" + prefixe.lower(),
"pubkey": cle})
return out
_locataires_backup = _locataires_sauvegarde()
# 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()
@ -152,6 +198,10 @@ def inventaire() -> dict:
for cle in ("domaine_interne", "organisation", "dns_amorcage"):
if intrants.get(cle):
communes[cle] = str(intrants[cle])
# L'identite de sauvegarde DU SITE (moitie publique). Le depot du site l'autorise
# pour ses propres machines ; les locataires ont chacun la leur, ailleurs.
if intrants.get("backup_pubkey"):
communes["serveur_backup_pubkey"] = str(intrants["backup_pubkey"])
if intrants.get("cle_ssh"):
communes["ansible_ssh_private_key_file"] = intrants["cle_ssh"]
if intrants.get("rebond"):
@ -269,6 +319,10 @@ def inventaire() -> dict:
if "serveur_resolveur_site" in _groupes_de(nom) else _zones_du_site),
# Ce que le marqueur verifiera : la liste ci-dessus les admet-elle vraiment ?
"serveur_resolveur_site_supernets_tenants": _supernets_tenants,
# Les ecosystemes qui deposent leur etat ici, un compte Unix chacun.
"serveur_backup_site_locataires": (
_locataires_backup
if "serveur_backup_site" in _groupes_de(nom) else []),
# Un role partage peut avoir besoin de savoir de quel cote il tourne.
"setops_site": True,
# Le plan du site, pour les roles qui lisent des registres.