From 0462dd01baf7046405211393d9ce98b315151457 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 24 Sep 2026 10:35:31 +0200 Subject: [PATCH] feat(scripts): chiffre au repos les volumes Docker de la VM par un coffre LUKS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...iffrement-au-repos-coffre-luks-et-sse-c.md | 98 ++++++++ docs/architecture/10-infra.md | 30 ++- infra/README.md | 5 + infra/terraform/environments/vm-eni/main.tf | 41 +++- .../vm-eni/terraform.tfvars.example | 4 + .../environments/vm-eni/variables.tf | 6 + scripts/README.md | 10 + scripts/coffre-luks.sh | 222 ++++++++++++++++++ 8 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md create mode 100755 scripts/coffre-luks.sh diff --git a/docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md b/docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md new file mode 100644 index 0000000..2d85256 --- /dev/null +++ b/docs/adr/0020-chiffrement-au-repos-coffre-luks-et-sse-c.md @@ -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. diff --git a/docs/architecture/10-infra.md b/docs/architecture/10-infra.md index b35e56f..528f9e5 100644 --- a/docs/architecture/10-infra.md +++ b/docs/architecture/10-infra.md @@ -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 diff --git a/infra/README.md b/infra/README.md index 6f548ce..69e3d77 100644 --- a/infra/README.md +++ b/infra/README.md @@ -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`) 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 desinscrit pas. diff --git a/infra/terraform/environments/vm-eni/main.tf b/infra/terraform/environments/vm-eni/main.tf index 17eb3be..db1588e 100644 --- a/infra/terraform/environments/vm-eni/main.tf +++ b/infra/terraform/environments/vm-eni/main.tf @@ -6,12 +6,15 @@ # 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 # 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. locals { sudo = var.ssh_user == "root" ? "" : "sudo " en_tant_que = "${var.ssh_user == "root" ? "" : "sudo "}runuser -u ${var.proprietaire} --" 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" # 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. @@ -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 # par environnement, son `.env` et son certificat. Il est rejouable : un `.env` existant n'est # jamais reecrit, un certificat present jamais regenere. resource "null_resource" "environnements" { - depends_on = [null_resource.docker_engine] + depends_on = [null_resource.docker_engine, null_resource.coffre] triggers = { script = filesha256(local.provisionneur) diff --git a/infra/terraform/environments/vm-eni/terraform.tfvars.example b/infra/terraform/environments/vm-eni/terraform.tfvars.example index 9f5df93..e5e8ec4 100644 --- a/infra/terraform/environments/vm-eni/terraform.tfvars.example +++ b/infra/terraform/environments/vm-eni/terraform.tfvars.example @@ -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 # seulement si deux runners doivent tourner sur la meme machine, leurs noms devant differer. # 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" diff --git a/infra/terraform/environments/vm-eni/variables.tf b/infra/terraform/environments/vm-eni/variables.tf index 41e46a0..1e402e6 100644 --- a/infra/terraform/environments/vm-eni/variables.tf +++ b/infra/terraform/environments/vm-eni/variables.tf @@ -94,3 +94,9 @@ variable "runner_dossier" { description = "Dossier d'installation du runner sur la machine." 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 = "" +} diff --git a/scripts/README.md b/scripts/README.md index 848aeb4..8914c5d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 crée deux comptes) : `BASE_URL=http://localhost:8000 ../../scripts/dast-token.sh`. Nécessite `curl`, `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. diff --git a/scripts/coffre-luks.sh b/scripts/coffre-luks.sh new file mode 100755 index 0000000..9c5335b --- /dev/null +++ b/scripts/coffre-luks.sh @@ -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" </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 < + 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 <