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
+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
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.
+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