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:
Johan LEROY
2026-09-24 10:35:31 +02:00
parent e53c7e441c
commit 0462dd01ba
8 changed files with 414 additions and 2 deletions
@@ -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.
+29 -1
View File
@@ -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 | | `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` | | `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) | | `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) | | `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` | | `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 | | `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 | | `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. 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 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 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 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 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. 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 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` | | 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` | | 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` | | 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` | | 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` | | 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 : 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. 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 ## Cible à terme, k3s
Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster, depuis la Statut : `En cours`. Le module `infra/terraform/modules/k3s/` installe le cluster, depuis la
+5
View File
@@ -44,6 +44,11 @@ zone, `prod`, `rec` et `dev` vers la machine, obtient un certificat Let's Encryp
renouvellement ; sans jeton, chaque environnement garde un certificat auto-signe. Le frontal SNI (`infra/front`) renouvellement ; sans jeton, chaque environnement garde un certificat auto-signe. Le frontal SNI (`infra/front`)
se demarre une fois depuis le dossier de la prod, `make front-up`. se demarre une fois depuis le dossier de la prod, `make front-up`.
Chiffrement au repos ([ADR 0020](../docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md)) :
`coffre_taille = "30G"` fait poser par `scripts/coffre-luks.sh` un coffre LUKS2 sous `/var/lib/docker/volumes` ;
vide par defaut, rien n'est pose. La premiere pose arrete Docker le temps de copier les volumes, et la cle
`/root/enervision-coffre.key` est a sauvegarder hors de la VM.
Retirer le runner se fait a la main, depuis les parametres du depot : `terraform destroy` ne le Retirer le runner se fait a la main, depuis les parametres du depot : `terraform destroy` ne le
desinscrit pas. desinscrit pas.
+40 -1
View File
@@ -6,12 +6,15 @@
# Contrainte : pas de provisioner `destroy` sur le runner. Il imposerait une connexion ne lisant # Contrainte : pas de provisioner `destroy` sur le runner. Il imposerait une connexion ne lisant
# que `self`, donc le chemin de la cle SSH dans le state, et `svc.sh uninstall` ne desinscrit pas # que `self`, donc le chemin de la cle SSH dans le state, et `svc.sh uninstall` ne desinscrit pas
# le runner cote GitHub : le retrait reste manuel, depuis les parametres du depot. # le runner cote GitHub : le retrait reste manuel, depuis les parametres du depot.
# Piege : seule exception a « un apply n'interrompt pas la stack » : la premiere pose du coffre
# (`coffre_taille` non vide, ADR 0020) arrete Docker le temps de copier les volumes.
# Ref : ADR 0009 et 0017 pour les trois environnements, `scripts/provision-host.sh` pour leur contenu. # Ref : ADR 0009 et 0017 pour les trois environnements, `scripts/provision-host.sh` pour leur contenu.
locals { locals {
sudo = var.ssh_user == "root" ? "" : "sudo " sudo = var.ssh_user == "root" ? "" : "sudo "
en_tant_que = "${var.ssh_user == "root" ? "" : "sudo "}runuser -u ${var.proprietaire} --" en_tant_que = "${var.ssh_user == "root" ? "" : "sudo "}runuser -u ${var.proprietaire} --"
provisionneur = "${path.root}/../../../../scripts/provision-host.sh" provisionneur = "${path.root}/../../../../scripts/provision-host.sh"
coffre = "${path.root}/../../../../scripts/coffre-luks.sh"
runner_archive = "actions-runner-linux-x64-${var.runner_version}.tar.gz" runner_archive = "actions-runner-linux-x64-${var.runner_version}.tar.gz"
# Substitution shell, evaluee par le sh -c distant : un nom de runner doit etre unique dans # Substitution shell, evaluee par le sh -c distant : un nom de runner doit etre unique dans
# le depot, le nom d'hote l'est deja et le reste si cette racine sert a une autre machine. # le depot, le nom d'hote l'est deja et le reste si cette racine sert a une autre machine.
@@ -50,11 +53,47 @@ resource "null_resource" "docker_engine" {
} }
} }
# Optionnel : un coffre LUKS2 dans un fichier image, bind-monte sur /var/lib/docker/volumes
# (ADR 0020). Rejouable : deja en place, le script affiche l'etat et sort sans rien toucher.
resource "null_resource" "coffre" {
count = var.coffre_taille == "" ? 0 : 1
depends_on = [null_resource.docker_engine]
triggers = {
script = filesha256(local.coffre)
taille = var.coffre_taille
}
connection {
type = "ssh"
host = var.ssh_host
port = var.ssh_port
user = var.ssh_user
private_key = file(pathexpand(var.ssh_private_key_path))
timeout = "5m"
}
provisioner "file" {
source = local.coffre
destination = "/tmp/coffre-luks.sh"
}
provisioner "remote-exec" {
inline = [
<<-EOT
set -eu
${local.sudo}env COFFRE_TAILLE='${var.coffre_taille}' COFFRE_MIGRER=1 bash /tmp/coffre-luks.sh
rm -f /tmp/coffre-luks.sh
EOT
]
}
}
# `provision-host.sh` verifie lui-meme docker, compose et la sortie HTTPS, puis prepare un clone # `provision-host.sh` verifie lui-meme docker, compose et la sortie HTTPS, puis prepare un clone
# par environnement, son `.env` et son certificat. Il est rejouable : un `.env` existant n'est # par environnement, son `.env` et son certificat. Il est rejouable : un `.env` existant n'est
# jamais reecrit, un certificat present jamais regenere. # jamais reecrit, un certificat present jamais regenere.
resource "null_resource" "environnements" { resource "null_resource" "environnements" {
depends_on = [null_resource.docker_engine] depends_on = [null_resource.docker_engine, null_resource.coffre]
triggers = { triggers = {
script = filesha256(local.provisionneur) script = filesha256(local.provisionneur)
@@ -20,3 +20,7 @@ runner_token = "A_RENSEIGNER"
# Nom du runner cote GitHub. Vide par defaut : le nom d'hote de la machine. A renseigner # Nom du runner cote GitHub. Vide par defaut : le nom d'hote de la machine. A renseigner
# seulement si deux runners doivent tourner sur la meme machine, leurs noms devant differer. # seulement si deux runners doivent tourner sur la meme machine, leurs noms devant differer.
# runner_nom = "eni-g3-bis" # runner_nom = "eni-g3-bis"
# Coffre LUKS des volumes Docker (ADR 0020). Vide ou absent : rien n'est pose. La premiere pose
# arrete Docker le temps de copier les volumes ; la cle reste sur la machine, a sauvegarder ailleurs.
# coffre_taille = "30G"
@@ -94,3 +94,9 @@ variable "runner_dossier" {
description = "Dossier d'installation du runner sur la machine." description = "Dossier d'installation du runner sur la machine."
default = "/opt/actions-runner" default = "/opt/actions-runner"
} }
variable "coffre_taille" {
type = string
description = "Taille du coffre LUKS qui chiffre /var/lib/docker/volumes (ADR 0020, scripts/coffre-luks.sh), ex. 30G. Vide : le coffre n'est pas pose. La premiere pose arrete Docker le temps de copier les volumes ; la cle reste sur la machine, a sauvegarder ailleurs."
default = ""
}
+10
View File
@@ -9,3 +9,13 @@ Prépare le scan DAST (`.github/workflows/dast.yml`) : sur une API déjà démar
d'accès sur la sortie standard. À lancer depuis `apps/backend`, contre une base **jetable** (il y d'accès sur la sortie standard. À lancer depuis `apps/backend`, contre une base **jetable** (il y
crée deux comptes) : `BASE_URL=http://localhost:8000 ../../scripts/dast-token.sh`. Nécessite `curl`, crée deux comptes) : `BASE_URL=http://localhost:8000 ../../scripts/dast-token.sh`. Nécessite `curl`,
`jq` et `openssl`. `jq` et `openssl`.
## coffre-luks.sh
Pose un coffre LUKS2 dans un fichier image creux et bind-monte `/var/lib/docker/volumes` depuis ce
coffre : les volumes des trois environnements de la VM sont chiffrés au repos sans toucher aux
fichiers Compose (issue #42, [ADR 0020](../docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md)).
Rejouable, en root sur la VM : `COFFRE_TAILLE=30G COFFRE_MIGRER=1 bash scripts/coffre-luks.sh`. Sans
`COFFRE_MIGRER=1`, le coffre est préparé mais les volumes existants ne sont pas déplacés : la
migration arrête Docker le temps de la copie. Variables : `COFFRE_IMAGE`, `COFFRE_CLE`,
`COFFRE_MONTAGE`, `COFFRE_TAILLE`. La clé est à sauvegarder hors de la VM : perdue, tout est perdu.
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env bash
# Pourquoi : les volumes Docker nommés des trois environnements (pgdata TimescaleDB, Garage,
# Airflow, Prometheus, Grafana) vivent en clair sous /var/lib/docker/volumes ; Garage n'a pas de
# chiffrement côté serveur et PostgreSQL communautaire n'a pas de TDE (issue #42, ADR 0020).
# coffre-luks.sh pose un coffre LUKS2 dans un fichier image creux, le monte, puis bind-monte
# /var/lib/docker/volumes depuis ce coffre : les trois projets Compose sont chiffrés au repos
# sans qu'un fichier Compose change. La clé vit sur le même disque que l'image : le coffre
# protège une copie isolée de l'image ou du disque (snapshot, sauvegarde, décommissionnement),
# pas le vol du disque entier ni un root sur l'hôte allumé, qui lit le montage en clair.
# Piège : le drop-in RequiresMountsFor sur docker.service est la seule barrière qui empêche
# Docker de recréer des volumes en clair si le coffre manque au démarrage ; retirer la ligne
# fstab du bind la désactive sans message. `nofail` partout, sinon un coffre absent envoie la
# machine en mode urgence et coupe SSH. Bind et drop-in ne sont posés qu'avec la migration :
# posés avant, un redémarrage masquerait les volumes en clair sous un coffre vide. Rejouable.
set -euo pipefail
COFFRE_IMAGE="${COFFRE_IMAGE:-/srv/enervision/coffre.img}"
COFFRE_CLE="${COFFRE_CLE:-/root/enervision-coffre.key}"
COFFRE_MONTAGE="${COFFRE_MONTAGE:-/srv/enervision/coffre}"
COFFRE_TAILLE="${COFFRE_TAILLE:-30G}"
COFFRE_MIGRER="${COFFRE_MIGRER:-0}"
MAPPER="enervision-coffre"
PERIPHERIQUE="/dev/mapper/$MAPPER"
VOLUMES="/var/lib/docker/volumes"
SOURCE_BIND="$COFFRE_MONTAGE/docker-volumes"
DROPIN="/etc/systemd/system/docker.service.d/enervision-coffre.conf"
APT_A_JOUR=0
erreur() { echo "erreur : $*" >&2; exit 1; }
if [[ $# -gt 0 ]]; then
echo "Usage : [COFFRE_TAILLE=30G] [COFFRE_MIGRER=1] [COFFRE_IMAGE=...] [COFFRE_CLE=...] [COFFRE_MONTAGE=...] $0" >&2
exit 2
fi
installer() {
local paquet="$1"
dpkg -s "$paquet" >/dev/null 2>&1 && return 0
if [[ $APT_A_JOUR -eq 0 ]]; then
apt-get update -qq
APT_A_JOUR=1
fi
apt-cache show "$paquet" >/dev/null 2>&1 || return 1
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends "$paquet" >/dev/null
echo "$paquet installé"
}
verifier_prerequis() {
[[ "$(id -u)" -eq 0 ]] || erreur "à lancer en root"
[[ "$COFFRE_IMAGE" != /var/lib/docker/* ]] \
|| erreur "l'image $COFFRE_IMAGE ne doit pas vivre sous /var/lib/docker, que le coffre recouvre"
installer cryptsetup || erreur "cryptsetup introuvable dans apt"
# Debian 13 sépare le générateur crypttab dans systemd-cryptsetup ; sans lui, crypttab est ignoré.
installer systemd-cryptsetup || echo "systemd-cryptsetup absent d'apt : le générateur crypttab est dans systemd"
installer rsync || erreur "rsync introuvable dans apt"
for outil in truncate blkid findmnt lsblk mkfs.ext4 systemctl; do
command -v "$outil" >/dev/null || erreur "$outil absent"
done
}
deja_sur_le_coffre() {
[[ "$(findmnt -n -o SOURCE "$VOLUMES" 2>/dev/null || true)" == *"$MAPPER"* ]]
}
poser_cle() {
if [[ ! -f "$COFFRE_CLE" ]]; then
(umask 077 && head -c 64 /dev/urandom > "$COFFRE_CLE")
echo "clé générée : $COFFRE_CLE"
fi
chmod 400 "$COFFRE_CLE"
}
poser_image() {
if [[ ! -f "$COFFRE_IMAGE" ]]; then
mkdir -p "$(dirname "$COFFRE_IMAGE")"
(umask 077 && truncate -s "$COFFRE_TAILLE" "$COFFRE_IMAGE")
echo "image creuse créée : $COFFRE_IMAGE ($COFFRE_TAILLE)"
fi
if ! cryptsetup isLuks "$COFFRE_IMAGE"; then
[[ -z "$(blkid -p -o value -s TYPE "$COFFRE_IMAGE" 2>/dev/null || true)" ]] \
|| erreur "$COFFRE_IMAGE porte déjà des données hors LUKS, refus de le formater"
cryptsetup luksFormat --type luks2 --batch-mode --key-file "$COFFRE_CLE" "$COFFRE_IMAGE"
echo "image formatée en LUKS2"
fi
if [[ ! -e "$PERIPHERIQUE" ]]; then
cryptsetup open --key-file "$COFFRE_CLE" "$COFFRE_IMAGE" "$MAPPER"
fi
if [[ -z "$(blkid -p -o value -s TYPE "$PERIPHERIQUE" 2>/dev/null || true)" ]]; then
mkfs.ext4 -q -L "$MAPPER" "$PERIPHERIQUE"
echo "système de fichiers ext4 créé dans le coffre"
fi
mkdir -p "$COFFRE_MONTAGE"
if ! findmnt -n -M "$COFFRE_MONTAGE" >/dev/null; then
mount "$PERIPHERIQUE" "$COFFRE_MONTAGE"
fi
mkdir -p "$SOURCE_BIND"
}
fstab_contient() {
awk -v cible="$1" '$1 !~ /^#/ && $2 == cible { trouve = 1 } END { exit !trouve }' /etc/fstab
}
poser_persistance() {
touch /etc/crypttab
if ! awk -v nom="$MAPPER" '$1 == nom { trouve = 1 } END { exit !trouve }' /etc/crypttab; then
echo "$MAPPER $COFFRE_IMAGE $COFFRE_CLE luks,nofail" >> /etc/crypttab
echo "crypttab : $MAPPER ajouté"
fi
if ! fstab_contient "$COFFRE_MONTAGE"; then
echo "$PERIPHERIQUE $COFFRE_MONTAGE ext4 defaults,nofail,x-systemd.device-timeout=30s 0 2" >> /etc/fstab
echo "fstab : $COFFRE_MONTAGE ajouté"
fi
systemctl daemon-reload
}
poser_bind() {
if ! fstab_contient "$VOLUMES"; then
echo "$SOURCE_BIND $VOLUMES none bind,nofail 0 0" >> /etc/fstab
echo "fstab : bind de $VOLUMES ajouté"
fi
if [[ ! -f "$DROPIN" ]]; then
mkdir -p "$(dirname "$DROPIN")"
cat > "$DROPIN" <<CONF
# Écrit par scripts/coffre-luks.sh (ADR 0020) : coffre absent au démarrage, Docker ne démarre
# pas, plutôt que de recréer des volumes en clair sous $VOLUMES.
[Unit]
RequiresMountsFor=$VOLUMES
CONF
echo "drop-in : $DROPIN écrit"
fi
systemctl daemon-reload
}
empreinte() { find "$1" -type f -printf '%s\n' | awk '{ n++; s += $1 } END { printf "%d fichiers, %d octets", n, s }'; }
libre() { df -B1 --output=avail "$1" | tail -1 | tr -d ' '; }
expliquer_migration() {
cat <<FIN
Le coffre est prêt, mais $VOLUMES n'y est pas encore : rien n'a changé pour Docker, un redémarrage
est sans risque. La migration arrête Docker, donc les trois environnements, le temps de copier les
volumes (une à trois minutes), puis le redémarre.
Volumes à copier : $(du -sh "$VOLUMES" | cut -f1), libre sur le coffre : $(df -h --output=avail "$COFFRE_MONTAGE" | tail -1 | tr -d ' ')
Libre sur le disque qui porte l'image, la copie occupant deux fois la place jusqu'à la
suppression de $VOLUMES.avant-coffre : $(df -h --output=avail "$(dirname "$COFFRE_IMAGE")" | tail -1 | tr -d ' ')
Pour la jouer : COFFRE_MIGRER=1 bash $0
FIN
exit 1
}
migrer() {
local origine copie
mkdir -p "$VOLUMES"
if [[ -z "$(ls -A "$VOLUMES")" ]]; then
systemctl stop docker.socket docker.service
poser_bind
mount "$VOLUMES"
systemctl start docker.socket docker.service
echo "aucun volume à migrer : bind monté, Docker redémarré"
return 0
fi
[[ "$COFFRE_MIGRER" == 1 ]] || expliquer_migration
if docker info 2>/dev/null | grep -q "Live Restore Enabled: true"; then
erreur "live-restore actif : les conteneurs survivraient à l'arrêt du démon, volumes en clair ouverts. Le désactiver dans /etc/docker/daemon.json avant de migrer"
fi
[[ "$(du -sb "$VOLUMES" | cut -f1)" -lt "$(libre "$COFFRE_MONTAGE")" ]] \
|| erreur "le coffre est trop petit pour $VOLUMES ($(du -sh "$VOLUMES" | cut -f1)) : relancer avec une image plus grande"
echo "arrêt de Docker : les trois environnements sont coupés le temps de la copie"
systemctl stop docker.socket docker.service
poser_bind
rsync -aHAX --numeric-ids "$VOLUMES/" "$SOURCE_BIND/"
origine="$(empreinte "$VOLUMES")"
copie="$(empreinte "$SOURCE_BIND")"
[[ "$origine" == "$copie" ]] \
|| erreur "copie incomplète : $origine dans $VOLUMES, $copie dans $SOURCE_BIND. Docker est arrêté, rien n'a été déplacé"
echo "copie vérifiée : $copie"
mv "$VOLUMES" "$VOLUMES.avant-coffre"
mkdir "$VOLUMES"
if ! mount "$VOLUMES" || ! deja_sur_le_coffre; then
umount "$VOLUMES" 2>/dev/null || true
rmdir "$VOLUMES"
mv "$VOLUMES.avant-coffre" "$VOLUMES"
erreur "bind impossible à monter depuis le coffre : $VOLUMES remis en place, Docker reste arrêté"
fi
systemctl start docker.socket docker.service
docker volume ls
}
afficher_etat() {
echo
losetup -j "$COFFRE_IMAGE" 2>/dev/null || true
lsblk "$PERIPHERIQUE" 2>/dev/null || true
findmnt "$VOLUMES" || echo "$VOLUMES n'est pas un point de montage"
cat <<FIN
À faire par l'opérateur :
1. Sauvegarder la clé hors de la VM, sans elle le coffre est perdu :
scp root@$(hostname -I | awk '{print $1}'):$COFFRE_CLE <emplacement sûr, hors de la machine>
2. Redémarrer la machine pour valider l'ordonnancement crypttab, fstab, docker, puis vérifier :
findmnt $VOLUMES && docker ps
FIN
if [[ -d "$VOLUMES.avant-coffre" ]]; then
cat <<FIN
3. Seulement après ce redémarrage validé, supprimer la copie en clair (non effaçable physiquement) :
rm -rf $VOLUMES.avant-coffre
FIN
fi
}
verifier_prerequis
if deja_sur_le_coffre; then
echo "$VOLUMES est déjà servi par le coffre $MAPPER, rien à faire"
afficher_etat
exit 0
fi
poser_cle
poser_image
poser_persistance
migrer
afficher_etat