feat(scripts): chiffre au repos les volumes Docker de la VM par un coffre LUKS
scripts/coffre-luks.sh pose un coffre LUKS2 dans un fichier image, bind-monte /var/lib/docker/volumes depuis ce coffre et exige son montage pour que Docker démarre (drop-in RequiresMountsFor). Migration à froid rejouable, jouée par l'opérateur ; null_resource Terraform optionnel (coffre_taille). Les archives déposées sur Garage sont chiffrées par SSE-C (clé GARAGE_SSE_KEY). ADR 0020, runbook et tableaux Garage dans 10-infra.md. Closes #42
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
# 0020 - Chiffrement au repos : coffre LUKS des volumes Docker et SSE-C des archives
|
||||
|
||||
- Statut : accepté
|
||||
- Date : 2026-09-24
|
||||
|
||||
## Contexte
|
||||
|
||||
L'issue #42 demande que les données de la plateforme soient chiffrées au repos. Tout ce que la
|
||||
plateforme persiste vit dans les volumes Docker nommés des trois projets Compose de la VM ENI
|
||||
([ADR 0009](0009-deux-environnements-compose-sur-la-vm-eni.md),
|
||||
[ADR 0017](0017-environnement-dev-a-la-demande.md)), sous `/var/lib/docker/volumes` : la base
|
||||
TimescaleDB (`pgdata`, relevés, comptes, audit), les métadonnées et les objets de Garage
|
||||
(`garage_meta`, `garage_data`, les archives des chunks de `reading` exportées par le DAG
|
||||
`retention`, [ADR 0019](0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)), les journaux et l'état ML d'Airflow, les séries de Prometheus et la base
|
||||
de Grafana.
|
||||
|
||||
Aucun des deux dépôts de données ne chiffre lui-même : Garage n'a pas de chiffrement côté serveur
|
||||
et sa documentation renvoie à un volume LUKS sous ses données ; PostgreSQL communautaire n'a pas de
|
||||
chiffrement transparent des données (TDE), et l'image `timescaledb-ha` n'en ajoute pas. La VM est
|
||||
unique, sur un seul disque virtuel, sans partition libre, sans TPM, et personne n'est devant sa
|
||||
console au démarrage : tout redémarrage doit aboutir sans saisie.
|
||||
|
||||
## Décision
|
||||
|
||||
**Un coffre LUKS2 sous tous les volumes Docker, posé par `scripts/coffre-luks.sh`.**
|
||||
|
||||
- Le coffre est un **fichier image creux** (`/srv/enervision/coffre.img`, 30 Go par défaut)
|
||||
formaté en **LUKS2**, ouvert par une **clé de 64 octets tirée de `/dev/urandom`**, lisible par
|
||||
root seulement (`/root/enervision-coffre.key`, `0400`). Un fichier plutôt qu'une partition : la
|
||||
VM n'en a pas de libre, et l'image se déplace ou se sauvegarde comme un fichier.
|
||||
- Le mapper `enervision-coffre` porte un ext4 monté sur `/srv/enervision/coffre`, et
|
||||
`/var/lib/docker/volumes` est **bind-monté** depuis `/srv/enervision/coffre/docker-volumes`.
|
||||
Docker ne voit qu'un dossier ordinaire : ni `data-root`, ni les fichiers Compose, ni les noms
|
||||
de volumes ne changent, et les trois environnements sont couverts d'un coup.
|
||||
- L'ouverture et les montages sont déclarés dans **`/etc/crypttab` et `/etc/fstab`, avec
|
||||
`nofail`** sur les trois lignes : un coffre absent ne doit jamais envoyer la machine en mode
|
||||
urgence, où SSH ne répond plus. Sur Debian 13 le générateur crypttab est dans le paquet
|
||||
`systemd-cryptsetup`, installé par le script s'il existe dans apt.
|
||||
- Un **drop-in `RequiresMountsFor=/var/lib/docker/volumes`** sur `docker.service` fait la
|
||||
barrière : sans le bind, Docker ne démarre pas, plutôt que de recréer des volumes vides en
|
||||
clair et de laisser trois stacks se lever sur des bases neuves.
|
||||
- Le script est **rejouable** : clé, image, formatage, système de fichiers, crypttab, fstab et
|
||||
drop-in ne sont posés que s'ils manquent, et il sort sans rien toucher si
|
||||
`/var/lib/docker/volumes` est déjà servi par le coffre. La **migration à froid** des volumes
|
||||
existants n'a lieu qu'avec `COFFRE_MIGRER=1` : refus si `live-restore` est actif, arrêt de
|
||||
`docker.socket` et `docker.service`, `rsync -aHAX --numeric-ids`, comparaison du nombre et de la
|
||||
taille des fichiers, puis bascule du dossier et redémarrage de Docker. L'ancien dossier reste en
|
||||
`/var/lib/docker/volumes.avant-coffre` jusqu'à validation par un redémarrage.
|
||||
- Terraform peut le jouer : `null_resource.coffre`, activé par `coffre_taille` non vide,
|
||||
s'exécute après Docker et avant `provision-host.sh`. La ressource est optionnelle et absente du
|
||||
plan tant que la variable est vide.
|
||||
|
||||
**SSE-C sur les archives exportées vers Garage.** Le module d'export du DAG `retention` envoie
|
||||
chaque archive avec une clé client (`GARAGE_SSE_KEY`, générée dans le `.env` par
|
||||
`provision-host.sh`) ; Garage la chiffre en AES-256-GCM et n'en garde que l'empreinte. Les objets
|
||||
sont donc chiffrés une seconde fois, avec une clé distincte de celle du coffre, dans le seul
|
||||
dépôt que l'on pourrait un jour sortir de la VM.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
| Écartée | Raison |
|
||||
|---|---|
|
||||
| `pgcrypto`, chiffrement par colonne | Ne couvre ni les index, ni les journaux WAL, ni Garage, ni Airflow ; la clé serait dans l'application, à côté des données, pour un coût de développement et de requête sur chaque lecture d'hypertable. |
|
||||
| Déplacer le `data-root` de Docker dans le coffre | Chiffre aussi les images et les couches, sans valeur, et impose de recopier tout `/var/lib/docker` : plus long, plus de place, et le démon doit être reconfiguré. Seuls les volumes portent des données. |
|
||||
| Chiffrer côté client dans le module d'export | Couvre les archives et rien d'autre, avec une bibliothèque cryptographique à porter dans le code métier alors que Garage offre SSE-C. Retenu seulement sous cette forme, en complément du coffre. |
|
||||
| Volume Docker chiffré par un plugin | Un plugin tiers par volume nommé, à installer et suivre sur la machine, pour huit volumes par environnement ; le coffre les couvre tous d'un bind. |
|
||||
| Disque ou partition dédiée | La VM n'a qu'un disque virtuel, sans partition libre, et son redimensionnement n'est pas dans les mains de l'équipe. |
|
||||
| Clé saisie au démarrage | Personne devant la console ; un redémarrage de la VM par l'école laisserait la plateforme arrêtée jusqu'à intervention. |
|
||||
| Clé scellée dans un TPM | La VM n'en expose pas. |
|
||||
|
||||
## Conséquences
|
||||
|
||||
- **Ce que le coffre protège, et ce qu'il ne protège pas.** La clé et l'image vivent sur le même
|
||||
disque. Le coffre protège une copie isolée de l'image ou du disque : snapshot, sauvegarde,
|
||||
décommissionnement du disque virtuel. Il ne protège ni du vol du disque entier, où la clé se
|
||||
trouve aussi, ni d'un root sur l'hôte allumé, qui lit le montage en clair. La copie
|
||||
`.avant-coffre`, supprimée après validation, n'est pas effaçable physiquement sur un disque
|
||||
virtuel. La clé SSE-C transite en clair sur le réseau Compose interne, entre `airflow-scheduler`
|
||||
et Garage, à chaque objet envoyé.
|
||||
- **Perte de la clé, perte de tout.** Sans `/root/enervision-coffre.key`, l'image est illisible
|
||||
et les trois bases avec elle. La clé est à sauvegarder hors de la VM tout de suite après la
|
||||
pose, dans un emplacement que seuls les administrateurs lisent.
|
||||
- **Coupure lors de la migration.** La copie des volumes se fait Docker arrêté : les trois
|
||||
environnements sont indisponibles une à trois minutes, et le disque doit porter deux fois la
|
||||
taille des volumes jusqu'à la suppression de `.avant-coffre`.
|
||||
- **Redémarrage de test obligatoire.** L'ordonnancement crypttab, fstab, drop-in ne se vérifie
|
||||
qu'en redémarrant : `findmnt /var/lib/docker/volumes` et `docker ps` après le reboot, avant de
|
||||
supprimer la copie en clair.
|
||||
- **Docker dépend du coffre.** Si l'image ou la clé disparaît, Docker refuse de démarrer
|
||||
(`dependency failed`) et la machine reste joignable par SSH ; c'est voulu. Retirer la ligne
|
||||
fstab du bind retire cette protection sans message.
|
||||
- **Rotation.** La clé LUKS se change par `cryptsetup luksChangeKey` sans réécrire les données.
|
||||
`GARAGE_SSE_KEY` ne se change pas sans réécrire chaque objet : Garage n'a pas de re-chiffrement
|
||||
côté serveur, et un objet écrit avec l'ancienne clé ne se lit qu'avec elle.
|
||||
- **Terraform interrompt la stack, une fois.** La première pose avec `coffre_taille` est la seule
|
||||
ressource de `vm-eni` qui arrête Docker, en contradiction assumée avec
|
||||
l'[ADR 0010](0010-terraform-provisionne-github-actions-deploie.md) pour cette seule occasion ;
|
||||
les `apply` suivants trouvent le coffre en place et n'y touchent pas.
|
||||
@@ -37,6 +37,7 @@ flowchart TB
|
||||
|---|---|---|
|
||||
| `db` | `timescale/timescaledb-ha:pg17` | Publié sur **5433** côté hôte, 5432 souvent déjà pris. `healthcheck` `pg_isready`, 12 tentatives, `start_period` 40s |
|
||||
| `backend` | Construite depuis `apps/backend` | `depends_on: db, condition: service_healthy`. **N'embarque pas le source** : toute modification impose `docker compose up -d --build backend` |
|
||||
| `garage` | `dxflrs/garage:v2.4.1` | S3 en `127.0.0.1:3900`, admin et `/metrics` en `127.0.0.1:3903`. `--single-node --default-bucket` : clé et bucket créés au premier démarrage, secrets par l'environnement (`GARAGE_*` du `.env`, garde dans le Makefile), `garage.toml` versionné sans secret dans `infra/garage`. Reçoit les archives du DAG `retention` ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)) |
|
||||
| `prometheus`, `alertmanager`, `grafana`, exporteurs | Images épinglées par tag | Profil `monitoring`, jamais démarrés par `make dev`. `make monitoring-up` les lance en `--no-deps`. Voir [60-observabilite.md](60-observabilite.md) |
|
||||
| `k6` | `grafana/k6` | Profil `load`, lancé par `make load-*` le temps d'un tir, sur le réseau du projet. Voir [`tests/load/README.md`](../../tests/load/README.md) |
|
||||
|
||||
@@ -91,6 +92,7 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md).
|
||||
| `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` |
|
||||
| `mock_api_import` | `45 * * * *` | `app.etl.mock_api_import`, dans `/opt/backend/.venv` ; importe depuis l'API Mock la mesure de l'heure pile précédant son déclenchement |
|
||||
| `derive` | `30 5 * * *` | `app.monitoring.drift`, dans `/opt/backend/.venv` ; quotidien parce que sa fenêtre couvre 168 h, et sans reprise parce qu'une dérive n'est pas une panne passagère |
|
||||
| `retention` | `20 3 * * *` | `app.etl.reading_retention`, dans `/opt/backend/.venv` ; exporte vers Garage (CSV gzip, SSE-C) chaque chunk de `reading` plus vieux que `READING_RETENTION_DAYS` puis le supprime par `drop_chunks` ; la nuit parce que la suppression verrouille `site` et `dataset` jusqu'au COMMIT |
|
||||
|
||||
Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique.
|
||||
Il reste manuel, car le dataset sert à initialiser l'environnement. Le montage
|
||||
@@ -179,7 +181,7 @@ du `docker-compose.yml` principal (réseau, volumes et démarrage séparés).
|
||||
Portée actuelle : environnement de tracking et de registre de modèles pour le développement
|
||||
local uniquement. Ce compose n'est relié ni à `docker-compose.prod.yml`, ni aux deux
|
||||
environnements Compose de la VM ENI, ni à la cible k3s. Le magasin utilisé par Airflow pour
|
||||
`ml_train`/`ml_score` (SQLite, volume `airflow_ml_state`) en est distinct — les deux MLflow ne
|
||||
`ml_train`/`ml_score` (SQLite, volume `airflow_ml_state`) en est distinct : les deux MLflow ne
|
||||
se voient pas tant que `MLFLOW_TRACKING_URI` n'est pas posé côté Airflow.
|
||||
|
||||
Limite connue : le DAG Airflow `ml_train` enregistre lui aussi une version a chaque execution
|
||||
@@ -253,6 +255,7 @@ les secrets et les certificats, et ne démarre rien.
|
||||
| URL | `https://dev.enervision-g3.dynv6.net` | `https://rec.enervision-g3.dynv6.net` | `https://prod.enervision-g3.dynv6.net` |
|
||||
| Proxy HTTP, HTTPS, PROXY protocol, sur `127.0.0.1` | `8083`, `9443`, `9444` | `8081`, `8443`, `8444` | `10080`, `10443`, `10444` |
|
||||
| PostgreSQL, Mailpit, Airflow, sur `127.0.0.1` | `5435`, `8027`, `8084` | `5434`, `8026`, `8082` | `5433`, `8025`, `8080` |
|
||||
| Garage S3, admin, sur `127.0.0.1` | `3920`, `3923` | `3910`, `3913` | `3900`, `3903` |
|
||||
| Supervision (profil `monitoring`) | à la demande, `make monitoring-up` | à la demande, `make monitoring-up` | active, `COMPOSE_PROFILES=monitoring` |
|
||||
| Grafana, Prometheus, Alertmanager, sur `127.0.0.1` | `3003`, `9092`, `9095` | `3002`, `9091`, `9094` | `3001`, `9090`, `9093` |
|
||||
|
||||
@@ -297,6 +300,31 @@ sonde de `deploy.yml` qui le dit.
|
||||
Le jeton d'enregistrement du runner est valable une heure et ne vaut que pour une inscription :
|
||||
l'`apply` n'est pas rejouable sans qu'un administrateur du dépôt en crée un nouveau.
|
||||
|
||||
### Coffre LUKS des volumes Docker (issue #42)
|
||||
|
||||
Statut : `En cours`, script écrit, jamais encore joué sur la VM. Décision et modèle de menace dans
|
||||
l'[ADR 0020](../adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md). Un fichier image LUKS2
|
||||
(`/srv/enervision/coffre.img`, clé `/root/enervision-coffre.key`) est monté sur
|
||||
`/srv/enervision/coffre`, et `/var/lib/docker/volumes` est bind-monté depuis ce coffre : les
|
||||
volumes des trois environnements sont chiffrés au repos sans qu'un fichier Compose change.
|
||||
`scripts/coffre-luks.sh` pose tout, rejouable ; Terraform le joue aussi quand `coffre_taille` est
|
||||
renseignée. Prérequis : deux fois la taille actuelle des volumes libre sur le disque, le temps de
|
||||
la migration. Runbook, joué en root sur la VM, coupure des trois environnements d'une à trois
|
||||
minutes :
|
||||
|
||||
```bash
|
||||
scp scripts/coffre-luks.sh root@10.101.200.37:/tmp/
|
||||
ssh root@10.101.200.37 'COFFRE_TAILLE=30G COFFRE_MIGRER=1 bash /tmp/coffre-luks.sh'
|
||||
ssh root@10.101.200.37 'findmnt /var/lib/docker/volumes && lsblk /dev/mapper/enervision-coffre && docker ps'
|
||||
ssh root@10.101.200.37 'curl -k https://localhost:10443/api/v1/health/ready'
|
||||
```
|
||||
|
||||
Ensuite, dans cet ordre : sauvegarder `/root/enervision-coffre.key` hors de la VM (sans elle, les
|
||||
trois bases sont perdues) ; redémarrer la machine et rejouer les deux vérifications, ce qui valide
|
||||
l'ordonnancement crypttab, fstab et drop-in Docker ; alors seulement supprimer la copie en clair,
|
||||
`rm -rf /var/lib/docker/volumes.avant-coffre`. Le script affiche ces trois étapes à la fin et
|
||||
n'exécute jamais la suppression.
|
||||
|
||||
## Cible à terme, k3s
|
||||
|
||||
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster, depuis la
|
||||
|
||||
Reference in New Issue
Block a user