sauvegarde : l etat d un locataire quitte enfin sa propre flotte
Some checks are pending
verifier / verifier (push) Waiting to run
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:
parent
4abf875e5f
commit
3ea0c95492
20 changed files with 715 additions and 122 deletions
79
CHANGELOG.md
79
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
playbooks/groupes/serveur_backup_site.yml
Normal file
15
playbooks/groupes/serveur_backup_site.yml
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
112
roles/serveur_backup/tasks/verification.yml
Normal file
112
roles/serveur_backup/tasks/verification.yml
Normal 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
|
||||
49
roles/serveur_backup_site/README.md
Normal file
49
roles/serveur_backup_site/README.md
Normal 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.*
|
||||
47
roles/serveur_backup_site/defaults/main.yml
Normal file
47
roles/serveur_backup_site/defaults/main.yml
Normal 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: []
|
||||
13
roles/serveur_backup_site/meta/authentification.yml
Normal file
13
roles/serveur_backup_site/meta/authentification.yml
Normal 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.
|
||||
7
roles/serveur_backup_site/meta/empreinte.yml
Normal file
7
roles/serveur_backup_site/meta/empreinte.yml
Normal 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
|
||||
29
roles/serveur_backup_site/meta/flux.yml
Normal file
29
roles/serveur_backup_site/meta/flux.yml
Normal 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
|
||||
75
roles/serveur_backup_site/meta/main.yml
Normal file
75
roles/serveur_backup_site/meta/main.yml
Normal 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"
|
||||
168
roles/serveur_backup_site/tasks/main.yml
Normal file
168
roles/serveur_backup_site/tasks/main.yml
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue