Aller au contenu principal

Procédure Break-Glass

Document de reconstruction du homelab en cas d'incident majeur (incendie, foudre, defaillance materielle, corruption).

Ce document doit être imprime et stocké avec la YubiKey hors-domicile (YK2)

Cette documentation est hébergée sur GitHub Pages, hors du lab. Une panne peut néanmoins vous priver du réseau ou des moyens d’accès : garder une copie papier ou PDF à jour avec YK2.


Objectifs de reprise​

MetriqueCibleJustification
RTO (Recovery Time Objective)4hZero a services critiques operationnels
RPO (Recovery Point Objective)24hBackup quotidien a 3h du matin
Services critiquesDNS, Vaultwarden, Traefik, AutheliaTout le reste en dépend

Architecture de référence​

Connaitre l'architecture est indispensable pour savoir quoi restaurer et dans quel ordre.

Schéma agrandi
100 %

Dépendances critiques (ordre de démarrage)​

1. DNS → AdGuard Home (penny ou dns-failover)
2. Reverse Proxy → Traefik (penny) — necessite CF_DNS_API_TOKEN
3. Passwords → Vaultwarden (LXC 102 galahad) — PAS de dependance SSO
4. SSO → Authelia (penny) — necessite secrets generes
5. Tout le reste → depend de 1-4
Vaultwarden est volontairement isolé du SSO

Il n'utilisé PAS Authelia pour éviter une dépendance circulaire. Il reste accessible même si Authelia est en panne.


Preparation AVANT l'incident​

YubiKeys break-glass (remplacé la clé USB depuis 2026-04-19)​

Deux YubiKeys sont des recipiants sops indépendants. Chacune peut déchiffrer TOUS les secrets sans l'autre.

YubiKeyLocalisationUsage
YK1Domicile (acces quotidien si besoin)DR sur host fresh + rotation secrets
YK2Hors-domicile (coffre ou lieu sur)Redondance pure — stocker avec ce doc imprime

Secrets couverts par les YubiKeys (dans homelab-config/) :

  • .env.enc — tous les tokens (Cloudflare, Tailscale, R2, ntfy...)
  • authelia/secrets/* — JWT, OIDC, session, storage keys
  • crowdsec/online_api_credentials.yaml, local_api_credentials.yaml
  • system/secrets/restic-env.enc — password restic + credentials Cloudflare R2 (AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY)
  • system/secrets/postfix-relay.env.enc — credentials Proton SMTP submission (token)

Workflow de déchiffrement avec YubiKey (sur n'importe quel host avec age-plugin-yubikey installe) :

# Installer age-plugin-yubikey (macOS)
brew install age-plugin-yubikey

# Exporter l'identite YubiKey (PIN + touch requis)
age-plugin-yubikey --identity > /tmp/yk-id.txt

# Dechiffrer un fichier sops
SOPS_AGE_KEY_FILE=/tmp/yk-id.txt sops --input-type dotenv --output-type dotenv -d system/secrets/restic-env.enc
SOPS_AGE_KEY_FILE=/tmp/yk-id.txt sops -d authelia/secrets/jwt_secret

# Supprimer l'identity apres usage
rm /tmp/yk-id.txt

Coffre physique (hors-domicile, avec YK2)​

  • YubiKey 2 (YK2) physique
  • PIN YK2 (note séparée ou Vaultwarden)
  • Master password Vaultwarden (memorise ou papier scelle)
  • Codes de récupération TOTP
  • Ce document en PDF
  • Clé USB portant homelab-break-glass-<date>.tar.age — voir l'encadré
Dépendance circulaire — fermée le 2026-08-26

Le drill du matin a buté sur ceci : homelab-config est privé, un git clone anonyme échoue, et c'est la toute première commande de la phase 3.

$ git ls-remote https://github.com/GabinSMD/homelab-config.git
fatal: could not read Username for 'https://github.com'

La chaîne se refermait sur elle-même : pour cloner il faut un identifiant GitHub ; cet identifiant est dans .env.enc, qui est dans le dépôt ; ou dans Vaultwarden, qui tourne sur le cluster qu'on reconstruit justement.

C'est l'option 2 qui a été retenue — une copie hors-ligne chiffrée — parce qu'elle ne dépend d'aucun tiers pendant la reprise : ni GitHub, ni Cloudflare, ni Vaultwarden. Un PAT dans le coffre aurait laissé la reprise suspendue à la disponibilité (et à la non-expiration) d'un service extérieur.

L'archive est produite par scripts/break-glass-bundle.sh dans homelab-config :

./break-glass-bundle.sh # sortie dans /mnt/ssd/break-glass
./break-glass-bundle.sh /media/cle-usb # directement sur la clé du coffre

Elle contient un git bundle --all des deux dépôts (donc toute l'histoire, .env.enc et system/secrets/ compris), un répertoire hors-git/ avec les deux fichiers Authelia que .gitignore exclut (depuis le 2026-09-23, voir plus bas), et un manifeste avec les SHA de HEAD et les commandes de reprise. Chiffrée avec age vers les trois mêmes destinataires que sops : clé logicielle de penny, YK1, YK2 — chacun ouvre seul, donc YK2, qui est déjà dans ce coffre, suffit.

Taille réelle : 4,6 Mo au 2026-09-24 (3,9 Mo au drill du 26/08), pas les 285 Mo estimés pendant le drill — un git bundle ne transporte que l'historique versionné, pas les 264 Mo de données d'exécution non suivies qui traînaient alors dans l'arborescence (sucre/ à l'époque, base SQLite d'Authelia, journaux). Le script vérifie son propre produit avant de rendre la main : déchiffrement réel, clonage des deux bundles, comparaison des HEAD, et présence de .env.enc dans le clone.

Ce que l'archive ne contient pas : l'état d'exécution non versionné — la base Authelia (donc les inscriptions 2FA), les bases CrowdSec, les volumes Docker. Ça, c'est le périmètre de restic/R2 et de PBS. L'archive débloque la phase 3, elle ne remplace pas les sauvegardes.

À refaire après toute rotation de secret ou changement de configuration notable. Le recopiage sur la clé du coffre n'est plus un geste à retenir : il est automatisé depuis le 2026-08-30, voir la section suivante.

Dépôt sur la clé du coffre — automatisé depuis le 2026-08-30​

Le geste « recopier l'archive sur la clé » est resté en attente plusieurs jours après le drill du 26/08. Un geste manuel qu'on doit se rappeler de faire n'est pas une procédure, c'est une intention. Désormais : brancher la clé COFFRE sur penny suffit.

Ce qui se déroule alors, sans intervention :

  1. udev reconnaît le volume par le label du système de fichiers (COFFRE), jamais par une lettre de disque — le SSD de penny est lui-même en USB et se ré-énumère (sda1 → sdb1 pendant l'incident du 29/08). Une règle qui viserait « le disque USB » pourrait viser le SSD.
  2. break-glass-auto-depot.sh régénère l'archive si des commits sont plus récents qu'elle, la dépose, puis démonte et remonte la clé avant de relire le sha256. Une relecture immédiate viendrait du cache de pages et validerait une clé défaillante, pleine ou arrachée trop tôt.
  3. La clé est démontée en fin de course : elle se débranche sans précaution.
  4. Confirmation par ntfy, échec compris.

Le rappel est assuré par break-glass-fraicheur.sh (timer quotidien), qui lit la trace laissée dans /var/lib/break-glass/dernier-depot :

  • urgent si un secret a bougé depuis l'archive (.env.enc, .sops.yaml, authelia/secrets, system/secrets) — la copie du coffre n'ouvre plus la production ;
  • routine passé 60 jours.

Il ne compte volontairement pas les commits : une journée de travail sur la documentation en produit dix, dont aucun ne change quoi que ce soit à la reprise. Un rappel qui se déclenche tous les jours n'informe plus.

La clé reste hors-ligne, au coffre

Elle n'est branchée que le temps du dépôt. Laissée en permanence sur penny, elle partagerait son sort — rançongiciel, effacement, incendie, vol — et cesserait d'être un break-glass. C'est précisément ce dont elle protège.

Depuis un Mac​

break-glass-depot-macos.sh fait la même chose quand la clé est branchée sur le MacBook. Trois pièges, tous rencontrés à la première exécution réelle — le script avait été écrit et testé depuis penny, en root, donc le vrai chemin n'avait jamais été parcouru :

  • PermitRootLogin no fait arriver la session SSH en utilisateur normal. Il faut donc que l'archive soit lisible par son groupe (corrigé dans break-glass-bundle.sh), que /var/lib/break-glass lui soit accessible en écriture, et que git accepte un dépôt appartenant à root (safe.directory).
  • Le script fait six aller-retours SSH : avec une clé matérielle, six saisies de PIN. Ajouter ControlMaster auto dans le ~/.ssh/config du Mac.
  • Si le démontage échoue (fenêtre Finder ouverte sur le volume), le script s'arrête plutôt que de valider une copie non vérifiée. Fermer la fenêtre et relancer, ne pas contourner.

Leçon générale : un script d'urgence testé depuis la machine qui l'héberge n'est pas testé.

Comptes externes (doivent rester actifs)​

CompteUsageVérification
Cloudflare R2 EUBucket homelab-backups (s3 endpoint <account-id>.eu.r2.cloudflarestorage.com)set -a; . /root/.restic-env; set +a; restic snapshots
Proton Mail[email protected] (custom domain) + SMTP submission tokenlogin proton.me, voir /account/u/0/mail/imap-smtp
CloudflareZone gabin-simond.fr, token DNS:EditDashboard Cloudflare
TailscaleTailnet, 3 machineslogin.tailscale.com
GitHubRepos homelab-config + homelab-docgithub.com/GabinSMD
ntfy.shTopic randomise (voir .env ou scripts)Notification test

Scénarios de panne​

Scénario 0 : Panne d'un seul LXC (dns-failover / vault / logs)​

Durée estimee : 15-30 min | Impact : service concerne uniquement

Impact : DNS secondaire indisponible. Le primaire (penny) prend tout le trafic. Aucune interruption visible.

# Depuis galahad
ssh gabins@pve1
# Verifier l'etat
pct status 100
# Tenter un restart
pct start 100
# Si le LXC est corrompu : recreer depuis le template
pct destroy 100
pct create 100 local:vztmpl/debian-12-standard_*.tar.zst \
--hostname dns-failover --memory 256 --cores 1 \
--net0 name=eth0,bridge=vmbr0,ip=192.168.1.30/24,gw=192.168.1.254 \
--unprivileged 1 --start 1
# Installer AdGuard Home + Tailscale + restaurer config depuis git
pct exec 100 -- bash -c "curl -s -S -L https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh | sh -s -- -v"
# Copier la config AdGuard depuis homelab-config

Scénario 1 : penny mort, cluster Proxmox OK​

Durée estimee : 1-2h | Impact : DNS primaire, Traefik, Authelia, Homepage — mais Vaultwarden reste UP

Bonne nouvelle

Vaultwarden (LXC 102) et AdGuard secondaire (LXC 100) restent operationnels. Les mots de passe et la resolution DNS de base continuent de fonctionner.

Le parcours praticable AUJOURD'HUI — a suivre dans cet ordre

Etabli par mesure le 2026-09-06. Le playbook ne remplace pas encore ce parcours : sa couche 1 s'arrete sur penny (voir plus bas). Suivre de haut en bas.

  1. Phase 1 — acquerir le materiel, flasher DietPi. Manuel pour toujours.
  2. Phase 2 — premier boot, mot de passe, clef SSH, monter le SSD, poser git, age, sops, ansible-core. Manuel pour toujours.
  3. Phase 3 — ouvrir le coffre, sortir la YubiKey, cloner les depots, desceller les VRAIS secrets. Manuel pour toujours. Garder cet ordre : la couche 1 ne pose plus de fixture hors du banc depuis le 2026-09-06 (voir le point 3 des pieges), mais l'ordre reste celui qui a du sens.
  4. Repli manuel, phase 3bis — les configs systeme. Lancer d'abord la couche 3 en lecture seule (bootstrap-pi.yml --check --diff) : elle dit ce que le depot ferait perdre. Puis copier.
  5. Repli manuel, phases 4 a 6 — Docker, volumes depuis R2, Authelia, reseau, scripts.
  6. verify.yml — le juge. Celui-la tourne sur penny : rc=0, mesure le 2026-09-06.

Ce que le playbook prend en charge sur penny aujourd'hui : l'etape 6, et l'etape 4 en lecture. La couche 1 est bloquee ; la couche 2 n'est bloquee par aucune garde mais n'a jamais ete jouee pour de vrai contre penny — le repli manuel reste donc la reference.

Phase 1 — Materiel (T+0 a T+15min)​

  1. Acquerir le materiel de remplacement :
    • Raspberry Pi 4 (8 Go RAM) + alimentation officielle 5.1V/3A
    • Carte SD 64 Go (ou récupérer l'existante si intacte)
    • SSD USB (ou récupérer l'existant)
  2. Flasher DietPi sur la SD depuis un autre appareil (dietpi.com)

Phase 2 — Amorce (T+15min a T+30min)​

Ce qui suit est hors de portee de tout playbook : il faut d'abord une machine joignable, un disque monte, et l'outil qui jouera la reconstruction.

# Premier boot : connexion console locale, mdp default DietPi
# Changer le password root immediatement
passwd

# Installer la cle SSH
mkdir -p ~/.ssh && chmod 700 ~/.ssh
# Copier la cle publique depuis la cle USB ou depuis GitHub
curl -s https://github.com/GabinSMD.keys >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

# Configurer le SSD (si nouveau, formater en ext4 avec le meme UUID)
# Si SSD recupere : monter directement
mkdir -p /mnt/ssd
mount /dev/sda1 /mnt/ssd

# Les outils dont la PHASE 3 a besoin, avant tout playbook.
# La couche 1 les pose aussi — trop tard : elle ne tourne qu'apres le coffre.
apt install -y git age

# sops par binaire verifie par somme, jamais curl-pipe-shell.
# Recette reprise mot pour mot de bootstrap-base.yml (couche 1).
cd /tmp
VER=$(curl -fsSL https://api.github.com/repos/getsops/sops/releases/latest \
| grep -oP '"tag_name": *"v\K[^"]+')
ARCH=$(dpkg --print-architecture)
curl -fsSLO "https://github.com/getsops/sops/releases/download/v${VER}/sops-v${VER}.linux.${ARCH}"
curl -fsSLO "https://github.com/getsops/sops/releases/download/v${VER}/sops-v${VER}.checksums.txt"
grep " sops-v${VER}.linux.${ARCH}$" "sops-v${VER}.checksums.txt" | sha256sum -c -
install -m 0755 "sops-v${VER}.linux.${ARCH}" /usr/local/bin/sops
age-plugin-yubikey n'existe pas dans Debian

age oui — 1.1.1-1+b3 en bookworm, verifie le 2026-09-06. Le plugin YubiKey, non : il faut son binaire de release GitHub, ou une machine tierce. Si la YubiKey est le seul recipiendaire disponible et qu'aucun binaire n'est joignable, la reprise est bloquee des la phase 3.

Parade : deposer sur la cle du coffre, a cote de l'archive, le binaire age-plugin-yubikey pour linux/arm64. Il ne coute que quelques megaoctets et il ferme une dependance a Internet dans le seul scenario ou l'on suppose justement Internet absent.

ansible-core n'est declare nulle part — a poser a la main

La reconstruction n'installe pas son propre outil. Mesure sur penny le 2026-09-06 : ansible-playbook y est un lien vers /root/.local/share/uv/tools/ansible-core/bin/, pose par uv tool install. Il n'est ni dans paquets_base (la liste de la couche 1), ni dans un manifeste de deploiement, ni dans aucune sauvegarde de binaire.

Debian bookworm propose ansible-core 2.14.18 ; penny joue ces playbooks sous 2.21.3. L'ecart de version n'a jamais ete eprouve — si le paquet Debian est le seul recours en reprise, s'attendre a devoir lire des erreurs de syntaxe avant que ca tourne.

Phase 3 — Ouvrir le coffre (T+30min a T+45min)​

Physique, et irremplacable : c'est ici qu'on sort la YubiKey et qu'on ouvre l'archive. Aucun playbook ne peut le faire a votre place.

# Depuis la cle USB du coffre — aucune authentification GitHub requise.
# YK2 est dans le meme coffre ; -i peut aussi pointer une cle age logicielle.
cd /mnt/ssd
age -d -i <cle-ou-yubikey> /media/cle-usb/homelab-break-glass-<date>.tar.age > bg.tar
tar xf bg.tar # -> homelab-config.bundle, homelab-doc.bundle, MANIFESTE.txt, hors-git/

# LE CHEMIN COMPTE : les playbooks exigent le depot a /mnt/ssd/config
# (`repo_root`), pas dans un repertoire de travail voisin.
#
# ET `git clone` REFUSE un repertoire non vide — « destination path already
# exists and is not an empty directory ». C'est le cas le PLUS PROBABLE ici :
# la phase 2 propose de reutiliser le SSD, qui porte alors deja le depot.
# Les trois branches ci-dessous ont ete eprouvees le 2026-09-06.
if [ -d /mnt/ssd/config/.git ]; then
echo "Depot deja present — NE RIEN ECRASER, c'est peut-etre plus a jour que le coffre."
git -C /mnt/ssd/config log --oneline -1
git -C /mnt/ssd/config status --short # des modifications non commitees ?
grep -A3 homelab-config MANIFESTE.txt # comparer au HEAD de l'archive
elif [ -e /mnt/ssd/config ]; then
# Present mais sans .git : on met de cote, on n'ecrase jamais.
mv /mnt/ssd/config "/mnt/ssd/config.avant-reprise-$(date +%Y%m%d-%H%M%S)"
git clone homelab-config.bundle /mnt/ssd/config
else
git clone homelab-config.bundle /mnt/ssd/config
fi

git clone homelab-doc.bundle /mnt/ssd/doc-repo # ce runbook, hors ligne

# Repli si la cle USB est perdue ET qu'un acces GitHub est disponible :
# git clone https://github.com/GabinSMD/homelab-config.git /mnt/ssd/config

# Poser la cle age la ou la production la cherche, puis desceller POUR DE VRAI.
# C'est la seule etape de secrets du parcours : ce que fait la couche 1 sur ce
# terrain est une FIXTURE de banc (voir l'avertissement plus bas).
mkdir -p /root/.config/sops/age
age-plugin-yubikey --identity > /root/.config/sops/age/keys.txt
chmod 600 /root/.config/sops/age/keys.txt
/mnt/ssd/config/scripts/homelab-unseal.sh # -> /run/homelab/.env (+ authelia, crowdsec)
La configuration Authelia EST dans le coffre depuis le 2026-09-23

Ce bloc avertissait du contraire jusqu'au 2026-09-24. Il etait perime — et c'etait le pire endroit pour l'etre, puisqu'on ne lit cette page qu'en situation, ou elle aurait envoye sur un contournement devenu inutile.

Le probleme, tel qu'il etait. authelia/configuration.yml (les 10 clients OIDC) et authelia/users_database.yml (le compte) sont exclus par .gitignore lignes 15-16, parce qu'ils portent des empreintes de secrets. break-glass-bundle.sh ne produisait que des git bundle --all : il n'embarquait donc que du suivi git. Une Pi reconstruite depuis la seule cle du coffre n'avait aucune authentification, donc aucun service joignable.

Ce qui a change. Le script copie desormais ces deux fichiers dans un repertoire hors-git/ de l'archive. Ils ne voyagent pas en clair : l'archive entiere est scellee par age, exactement comme les bundles et .env.enc.

Apres tar xf bg.tar, ils sont donc la :

ls hors-git/authelia/ # configuration.yml users_database.yml
cp -a hors-git/authelia/*.yml /mnt/ssd/config/authelia/

Trois gardes protegent cette promesse, et toutes jugent le travail accompli plutot que l'intention :

GardeCe qu'elle exige
Etape 2bun fichier declare mais illisible arrete le script
Etape 4on relit le tar et on exige que chaque fichier annonce y soit — compter les entrees de la liste reviendrait a se comparer a soi-meme, et vider la liste rendrait vert
Etape 5l'aller-retour age compare les empreintes apres dechiffrement, pas la simple presence : un fichier tronque existe

Le defaut avait ete trouve le 2026-09-06. Le drill du 26/08 ne l'avait pas vu parce qu'il prouvait la restauration des donnees, pas celle de la pile de configuration.

Phases 4 a 6 — le playbook, et ce qu'il ne fait pas encore sur penny​

Ce que faisaient a la main les phases 4 (Docker + services), 5 (Authelia) et 6 (reseau + verification), et la moitie automatisable de la phase 3 (les configs systeme), est desormais un playbook — decrit en detail dans Derive de configuration. Il est eprouve contre un banc jetable — contrairement a ce runbook, qui a pourri trois mois et demi sans que personne le sache.

Lire l'encadre rouge sous ce bloc AVANT de copier les commandes

La couche 1 s'arrete aujourd'hui sur penny, les trois exigent un drapeau explicite, et la couche 3 n'a jamais tourne pour de vrai. Tout est mesure et detaille juste dessous ; un repli manuel complet suit plus bas.

cd /mnt/ssd/config/ansible

# Couche 1 — paquets, Docker, sops, Tailscale (installe, PAS enrole), Alloy
ansible-playbook playbooks/bootstrap-base.yml -e cible=penny \
-e bootstrap_autorise=true -e age_key_path=/root/.config/sops/age/keys.txt

# Couche 2 — socket-proxy, traefik, authelia, adguard, homepage
ansible-playbook playbooks/bootstrap-stack.yml -e cible=penny -e bootstrap_autorise=true

# Couche 3 — Pi et DietPi. aarch64 uniquement. LIRE le --check avant d'appliquer.
ansible-playbook playbooks/bootstrap-pi.yml -e bootstrap_autorise=true --check --diff
ansible-playbook playbooks/bootstrap-pi.yml -e bootstrap_autorise=true

# Le verdict
ansible-playbook playbooks/verify.yml -e cible=penny

verify.yml rend le verdict. Il ne dit pas « les conteneurs sont demarres » mais « les services SERVENT » — un 302 d'Authelia vient du middleware et non du backend, et CyberChef est reste mort 24 h derriere un 302. Il porte aussi un controle negatif : un hote qui ne peut correspondre a aucun routeur doit obtenir une reponse DIFFERENTE, faute de quoi la sonde mesurerait une redirection inconditionnelle au lieu d'un routage.

Execute le 2026-09-06 sur les deux cibles :

CibleResultat
banc (LXC 111)ok=11 failed=0 — 14 clefs descellees
penny (production)ok=11 failed=0 — 37 clefs descellees
Ce qui BLOQUERA la premiere vraie reconstruction — mesure, pas suppose

Le 2026-09-06, les trois couches ont ete jouees contre penny en lecture seule. Voici ce qu'elles rendent, mot pour mot. Lire avant, pas pendant.

1. bootstrap_autorise est a false pour penny, exprès. Sans le drapeau :

$ ansible-playbook playbooks/bootstrap-base.yml -e cible=penny --check
fatal: [penny]: FAILED! => penny n'autorise pas ce playbook
(bootstrap_autorise = False). Cette couche installe des dizaines de paquets,
ajoute trois depots apt tiers et purge le cache apt de la cible.

C'est un cran de securite, pas un defaut : penny est la production. En reprise apres sinistre, c'est bien l'intention — on passe le drapeau.

2. La couche 1 ne tourne PAS sur penny aujourd'hui. Avec le drapeau, elle s'arrete a sa troisieme tache :

$ ansible-playbook playbooks/bootstrap-base.yml -e cible=penny \
-e bootstrap_autorise=true -e age_key_path=/root/.config/sops/age/keys.txt --check
TASK [Paquets de base] *********************************************************
fatal: [penny]: FAILED! => Error while resolving value for 'name':
'paquets_base' is undefined

paquets_base n'est declare que dans inventory/group_vars/banc.yml. Les couches 1 et 2 ont ete construites pour le banc ; les porter sur penny demande de decider quelle liste de paquets penny recoit — une decision, pas une variable a recopier. Tant qu'elle n'est pas prise, les phases 4 a 6 ne sont pas automatisees de bout en bout sur penny, et il faut retomber sur les gestes de l'ancien runbook, conserves plus bas.

3. La couche 1 scelle une FIXTURE — et depuis le 2026-09-06, sur le banc SEULEMENT. Ses trois dernieres taches chiffrent ansible/fixtures/banc/.env.plain — des valeurs factices — et les descellent vers /run/homelab/.env, le fichier meme que lit la production.

Jusqu'au 2026-09-06 elles n'etaient pas conditionnees au banc, et leur seul garde-fou etait un creates: : sur une penny fraiche, /run/homelab est vide, donc ce garde-fou ne retenait rien. Les 14 valeurs sont factices mais les 14 noms sont ceux de la production : tous les ${VAR:?} du compose auraient ete satisfaits, rien n'aurait crie, et la couche 2 — banc_mode faux, donc ACME arme — serait partie demander un certificat pour le vrai domaine avec CF_DNS_API_TOKEN=banc-cloudflare-factice. Un defi DNS-01 en rafale, et le quota Let's Encrypt du domaine brule. Ce qui l'empechait n'etait qu'un accident de plus : la couche 1 mourait sur paquets_base avant d'y arriver.

Les trois taches portent desormais when: banc_mode | bool, le meme mecanisme que les vingt-huit taches de surcharge de la couche 2. Verifie en le provoquant, le 2026-09-06 : -e cible=penny -e bootstrap_autorise=true rend skipping: [penny] sur les trois, et ok=12 skipped=0 sur le banc.

L'ordre de cette page reste le bon — la phase 3 desselle les VRAIS secrets avant la couche 1 — mais ce n'est plus lui qui vous protege.

4. --check n'est pas un apercu valide de la couche 2. Ses commandes docker compose sont sautees en mode verification, et l'attente qui suit rapporte alors « aucun conteneur » sur une machine qui en fait tourner cinq (mesure sur penny). Un --check rouge sur la couche 2 ne prouve rien.

Ce que le playbook ne fait pas, et qui reste ci-dessous : les 17 autres services de la stack (docker/docker-compose.yml en declare 22, moins les cinq de la couche 2 — compte du 2026-09-06), la restauration des volumes depuis R2 (phase 4 du repli manuel), la configuration Authelia (phase 5), les certificats TLS reels — la couche 2 pose un certificat auto-signe sur le banc et laisse ACME desarme — et l'enrolement Tailscale, qui exige une clef d'authentification et reste un geste humain, au meme titre que l'ouverture du coffre.

Il n'exprime pas non plus l'ordre de demarrage des dependances : c'est le tableau en tete de cette page, et lui seul.

La couche 3 n'a jamais tourne pour de vrai, et le premier essai s'arretera

Elle ne peut pas etre eprouvee sur le banc : un LXC amd64 n'a ni /boot/firmware, ni carte SD, ni DietPi, ni vcgencmd. Ce qui a ete fait a la place — penny comme oracle, --check --diff, cinq fautes provoquees contre le pre-vol — prouve la non-regression, pas la reconstruction.

Le pre-vol de non-perte ARRETERA la premiere vraie reconstruction. C'est certain, et c'est voulu : une image DietPi fraiche porte des directives que penny n'a pas (dtparam=audio=on la ou penny a off, un quiet ou un fsck.repair=yes dans cmdline.txt, des lignes de config.txt posees par defaut). Chacune sortira en PERDUE et l'assertion tombera. La marche a suivre est dans le message d'echec : verser au depot ce qu'on garde, inscrire dans pi_abandons ce qu'on laisse tomber. Qui decouvre ca en pleine reconstruction prendra une garde qui fonctionne pour un playbook casse.

Deux divergences de config.txt ne sont pas tranchees. Mesure le 2026-09-06 par --check --diff sur penny — c'est le seul changed de tout le playbook :

--- before: /boot/firmware/config.txt
+++ after: /mnt/ssd/config/boot/config.txt
-max_framebuffers=1
+max_framebuffers=0
-#hdmi_ignore_hotplug=1
+hdmi_ignore_hotplug=1

Le pre-vol ne mord pas dessus, et il a raison : max_framebuffers est une cle scalaire presente des deux cotes (la valeur du depot fait foi), et hdmi_ignore_hotplug est un ajout, pas une perte. Une execution reelle remettrait donc les valeurs du depot — moins de memoire video, HDMI ignore. C'est vraisemblablement l'intention d'origine sur une machine sans ecran, mais personne ne l'a decide. A trancher avant, pas pendant.

/boot/dietpi.txt est ecrit par la couche 3 et n'est pas versionne. La fonction dietpi-set_swapfile y persiste AUTO_SETUP_SWAPFILE_SIZE et AUTO_SETUP_SWAPFILE_LOCATION. Aucune sonde ne le compare a quoi que ce soit. Reserve connue, pas un oubli.

Vouloir verifier cette phrase a ouvert un defaut bien plus grave, corrige le jour meme et raconte en detail dans Sauvegardes. Le resume, parce qu'il change ce que vaut une restauration a froid :

homelab_backup.sh construisait sa liste de chemins avec [ -d "$p" ] && BACKUP_PATHS+=("$p"). -d ne repond vrai que pour un repertoire : toute entree de type FICHIER etait jetee sans un mot, et la sauvegarde se declarait « OK ». Deux entrees en sont mortes — /boot/firmware/dietpi.txt depuis le 2026-05-11, et l'etat OpenTofu des dix LXC depuis le 2026-09-04. Verifie contre le depot R2 : aucun dietpi.txt dans aucun snapshot, et l'etat absent des nuits du 05 et du 06/09.

Au passage, l'entree elle-meme designait le mauvais fichier. Ce sont deux fichiers distincts, pas un lien :

$ ls -l /boot/dietpi.txt /boot/firmware/dietpi.txt
-rw-r--r-- 1 root root 18518 Aug 27 10:25 /boot/dietpi.txt <- le VIVANT (rootfs, 222 references dans /boot/dietpi/)
-rwxr-xr-x 1 root root 18389 Mar 3 2025 /boot/firmware/dietpi.txt <- l'image d'origine (vfat, 0 reference)

Quatorze lignes different : la copie de l'image porte AUTO_SETUP_SWAPFILE_SIZE=0 la ou la machine tourne a 2048, et AUTO_SETUP_GLOBAL_PASSWORD=dietpi — le mot de passe par defaut, en clair, que le fichier vivant a justement remplace par un commentaire.

Les deux sont corriges depuis le 2026-09-06 : le filtre teste -e, un chemin declare et introuvable fait crier le script au lieu d'etre saute, et CONFIG_PATHS designe desormais /boot/dietpi.txt. Meme motif que le cp vers adguard/ plus haut : un chemin qui a l'air bon, une sauvegarde qui rend vert, et rien du contenu attendu dedans.

Enfin, la couche 3 ne redemarre pas. Son gestionnaire le dit et s'arrete la : dietpi-update a deja coupe une session par un postinst tailscale, et un redemarrage automatique en fin de reconstruction empecherait de constater le resultat. Redemarrer a la main, puis rejouer verify.yml.

Repli manuel — les phases 3bis a 6 a la main​

Ce qui suit est l'ancien parcours manuel. Il reste ici pour une raison precise, mesuree le 2026-09-06 : la couche 1 s'arrete sur 'paquets_base' is undefined des qu'on la vise sur penny. Le supprimer laisserait ce runbook sans aucun chemin praticable pour la machine qu'il est cense reconstruire.

Il resterait utile de toute facon : la restauration des volumes depuis R2 et la configuration Authelia ne sont dans aucun playbook.

Phase 3bis — appliquer les configs systeme a la main. Equivalent manuel de la couche 3. Les cp ci-dessous ecrasent sans regarder : c'est exactement ce que le pre-vol du playbook existe pour empecher. Le 2026-08-27, /boot/firmware/config.txt a recu dtoverlay=ramoops-pi4,... — le seul temoin qui survive a un reset dur — et le depot ne l'a recu que le 2026-09-06. Comparer avant de copier.

cd /mnt/ssd/config
diff boot/config.txt /boot/firmware/config.txt # LIRE avant d'ecraser
diff boot/cmdline.txt /boot/firmware/cmdline.txt

cp boot/config.txt /boot/firmware/config.txt
cp boot/cmdline.txt /boot/firmware/cmdline.txt
cp udev/50-argon-ssd.rules /etc/udev/rules.d/
udevadm control --reload-rules
cp system/fstab /etc/fstab # Adapter UUID si SSD different
cp system/97-dietpi.conf /etc/sysctl.d/
cp system/99-sysctl.conf /etc/sysctl.conf
cp system/crontab /var/spool/cron/crontabs/root
cp network/interfaces /etc/network/interfaces # IP 192.168.1.28
sysctl --system

Phase 4 (repli manuel) — Docker + services (T+45min a T+1h30)​

Corrigé le 2026-11-04 — la boucle restaurait des volumes qui n'étaient plus sauvegardés

Jusqu'à cette date, la boucle ci-dessous restaurait beszel et portainer. Or homelab_backup.sh stage crowdsec, traefik-certs et adguard-runtime depuis le 2026-04-17, sous des étiquettes qui ne sont pas les noms de volume. Suivie à la lettre, la procédure ne restaurait ni les certificats TLS ni l'état CrowdSec. La liste fait désormais foi dans VOLUMES= de scripts/homelab_backup.sh : la relire avant d'exécuter, et ajuster la boucle si elle a changé.

Corrigé le 2026-08-26 — les commandes de restauration ne pouvaient pas s'authentifier

Jusqu'à cette date, les blocs de restauration de cette page exportaient B2_ACCOUNT_ID B2_ACCOUNT_KEY. Or /root/.restic-env ne contient plus ces variables depuis la migration vers Cloudflare R2 du 2026-05-11 : il porte AWS_ACCESS_KEY_ID et AWS_SECRET_ACCESS_KEY.

Et comme ce fichier utilise des assignations simples, sans export, un source seul ne les transmet pas à restic, qui est un processus enfant. La procédure échouait donc sur une erreur d'authentification — pendant une reprise après sinistre, c'est-à-dire au pire moment.

Cassé pendant trois mois et demi sans que rien ne le signale : aucun chemin de code n'exécute cette page. Un runbook n'est vérifié que par un exercice de reprise qui le suit à la lettre.

Le correctif n'est pas de mettre à jour la liste des variables — ce serait le même piège, reporté au prochain changement de backend. C'est set -a, qui auto-exporte tout ce que le fichier assigne, sans rien coder en dur. C'est déjà ce que fait vault-lxc-backup.sh en production depuis le début ; la page, elle, ne l'avait jamais repris.

# Installer les paquets essentiels
dietpi-software install 162 # Docker
apt install -y fail2ban restic watchdog unattended-upgrades

# Configurer Docker
mkdir -p /etc/docker
cp /mnt/ssd/config/docker/daemon.json /etc/docker/daemon.json
systemctl restart docker

# Les configs applicatives sont DEJA en place : la phase 3 a clone le depot
# directement dans /mnt/ssd/config, qui EST le chemin lu par la production
# (bind-monte dans Traefik, en `watch: true`). Rien a recopier.
# (Corrige le 2026-09-06 : cette etape faisait trois `cp -r` depuis un clone
# voisin. L'un visait `adguard/`, chemin deplace vers `adguard/adguard-prod-1/`
# le 03/09 ; les deux autres seraient devenus des copies sur elles-memes.)
ls -d /mnt/ssd/config/adguard/adguard-prod-1 \
/mnt/ssd/config/traefik \
/mnt/ssd/config/homepage

# Les secrets applicatifs ont ete descelles en phase 3 par homelab-unseal.sh
# vers /run/homelab/ (tmpfs). Restent ceux de restic, hors de son perimetre.
# Execute le 2026-09-06 : rc=0, 8 assignations — RESTIC_REPOSITORY,
# RESTIC_PASSWORD, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, R2_ACCOUNT_ID,
# R2_BUCKET, R2_ENDPOINT, B2_CAP_BYTES.
# (Cette page a d'abord annonce « 4 clefs » : le compte venait d'un
# `grep -cE '^[A-Z_]+='`, qui ne matche pas un nom contenant un CHIFFRE et
# jetait donc les quatre entrees R2_/B2_. Compter avec
# `grep -cE '^[A-Za-z_][A-Za-z0-9_]*='`.)
SOPS_AGE_KEY_FILE=/root/.config/sops/age/keys.txt \
sops --input-type dotenv --output-type dotenv \
-d /mnt/ssd/config/system/secrets/restic-env.enc > /root/.restic-env
chmod 600 /root/.restic-env

# Le docker-compose est DEJA en place : le clone restaure le porte a
# docker/docker-compose.yml, qui est le chemin que lit la production.
# (Corrige le 2026-09-04 : cette etape faisait un `cp` vers la RACINE du depot,
# disposition d'avant le deplacement du compose sous docker/. Elle fabriquait
# un doublon au lieu de restaurer quoi que ce soit. Le chemin mort n'est PAS
# ecrit en entier ici : le controle de chemins vivants — section « Maintenance
# de ce document » — grep le texte de la page et ne sait pas distinguer une
# prescription d'un recit. Un controle qui signale toujours la meme chose
# n'informe plus.)
ls -l /mnt/ssd/config/docker/docker-compose.yml

# Restaurer les volumes Docker depuis restic (Cloudflare R2 EU, chiffre AES-256)
set -a; . /root/.restic-env; set +a # set -a auto-exporte : immune au prochain changement de backend

# Lister les snapshots disponibles
restic snapshots

# Restaurer les volumes stages dans le dernier snapshot
restic restore latest --target /tmp/restore --include "/mnt/ssd/.restic-staging"

# Injecter les donnees dans les volumes Docker.
# Paires volume:etiquette, recopiees de VOLUMES= dans scripts/homelab_backup.sh
# (l'etiquette est le nom du repertoire de staging, PAS le nom du volume).
grep -A8 '^VOLUMES=(' /mnt/ssd/config/scripts/homelab_backup.sh # verifier avant
for entry in docker_crowdsec-data:crowdsec \
config_traefik-certs:traefik-certs \
config_adguard-data:adguard-runtime; do
vol="${entry%%:*}"; label="${entry##*:}"
docker volume create "$vol" 2>/dev/null
docker run --rm \
-v "$vol":/data \
-v "/tmp/restore/mnt/ssd/.restic-staging/$label":/source:ro \
alpine sh -c "cp -a /source/. /data/"
echo "Restored: $vol (depuis $label)"
done

rm -rf /tmp/restore

Phase 5 (repli manuel) — Authelia (T+1h30 a T+1h45)​

# Restaurer la config Authelia depuis restic ou regenerer les secrets
# (les configs bind-mount sont deja restaurees par restic dans /mnt/ssd/config/)
set -a; . /root/.restic-env; set +a # set -a auto-exporte : immune au prochain changement de backend
restic restore latest --target /tmp/restore --include "/mnt/ssd/config/authelia"
if [ -d /tmp/restore/mnt/ssd/config/authelia ]; then
cp -a /tmp/restore/mnt/ssd/config/authelia/. /mnt/ssd/config/authelia/
rm -rf /tmp/restore
echo "Authelia restauree depuis restic"
else
# Regenerer depuis les templates (cle USB pour les secrets)
cp authelia/configuration.yml.example /mnt/ssd/config/authelia/configuration.yml
cp authelia/users_database.yml.example /mnt/ssd/config/authelia/users_database.yml
# Generer les secrets
openssl rand -hex 32 # jwt_secret
openssl rand -hex 32 # session_secret
openssl rand -hex 32 # storage_key
openssl rand -hex 32 # hmac_secret
openssl genrsa -out /mnt/ssd/config/authelia/oidc.pem 4096
# ATTENTION : si on regenere oidc.pem, il faut reconfigurer
# les 6 clients OIDC (Proxmox, Grafana, PBS, Pulse, Forgejo, Outline)
echo "SECRETS REGENERES — mettre a jour configuration.yml manuellement"
fi

Phase 6 (repli manuel) — Réseau et vérification (T+1h45 a T+2h)​

# Installer et configurer Tailscale
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up --authkey=<TS_AUTHKEY_DEPUIS_CLE_USB>
# Ou se connecter via login.tailscale.com si pas d'authkey

# Deployer les scripts et les units — le manifeste Ansible, pas des `cp`.
# 109 paires source -> copie live sur les trois hotes ; `--check --diff`
# d'abord, LIRE le diff, puis appliquer. Voir la page « Derive de
# configuration ». Les `cp` manuels qui figuraient ici ne couvraient que deux
# fichiers sur les 45 scripts et 51 units que le manifeste de penny declare
# (compte le 2026-09-06 dans ansible/inventory/group_vars/penny.yml).
cd /mnt/ssd/config/ansible
ansible-playbook playbooks/deploy-scripts.yml --limit penny --check --diff
ansible-playbook playbooks/deploy-systemd.yml --limit penny --check --diff
# puis les deux memes sans --check

# Lancer la stack. Le projet s'appelle `config` (declare par `name:` en tete du
# compose) et non `docker` : c'est ce qui donne aux volumes leur prefixe
# `config_`, celui que la restauration ci-dessus recree.
cd /mnt/ssd/config/docker
docker compose up -d

# Verifier que restic fonctionne
set -a; . /root/.restic-env; set +a # set -a auto-exporte : immune au prochain changement de backend
restic snapshots # doit lister les snapshots existants

Vérification post-restauration penny​

Le juge est verify.yml — il demande aux services de SERVIR, et il porte un controle negatif. Le tableau ci-dessous couvre ce qu'il ne regarde pas (materiel, ASPM, Tailscale, sauvegardes).

cd /mnt/ssd/config/ansible
ansible-playbook playbooks/verify.yml -e cible=penny
CheckCommandeAttendu
SSD montemount | grep ssd/mnt/ssd en rw
ASPM offcat /sys/module/pcie_aspm/parameters/policy[off]
DNS primairedig home.gabin-simond.fr @192.168.1.28192.168.1.28
Traefik HTTPScurl -sk https://home.gabin-simond.frHTTP 200 ou redirect auth
Autheliacurl -sk https://auth.home.gabin-simond.frPage de login
Containers UPdocker ps --format "table {{.Names}}\t{{.Status}}"Tous Up
Monitoringcat /var/lib/homelab_monitor/heartbeat puis comparer a date +%sHorodatage de moins de 15 min
Tailscaletailscale statusConnecte au tailnet
Backup resticset -a; . /root/.restic-env; set +a; restic snapshots > /tmp/s.txtrc=0, snapshots recents dans /tmp/s.txt

Scénario 2 : Cluster Proxmox detruit (galahad + lancelot)​

Durée estimee : 2-3h | Impact : Vaultwarden, DNS secondaire, Grafana, Proxmox UI

Vaultwarden est sur le cluster

Vérifier l'acces a l'app mobile Bitwarden (sync offline) avant de commencer. Les tokens critiques doivent aussi être sur la clé USB.

Phase 1 — Reinstaller Proxmox VE (T+0 a T+45min)​

# Sur chaque ZimaBoard : boot depuis USB Proxmox VE 9
# IMPORTANT : patch eMMC avant installation
# Ctrl+Alt+F2 depuis l'installeur Proxmox, puis :
# (voir proxmox-fix-emmc.sh dans homelab-config/scripts/)

# Apres installation et reboot, appliquer le post-install depuis penny :
cd /mnt/ssd/config/scripts && python3 -m http.server 8888

# Sur galahad (192.168.1.18) :
wget -O- http://192.168.1.28:8888/proxmox-post-install.sh | bash

# Sur lancelot (192.168.1.19) :
wget -O- http://192.168.1.28:8888/proxmox-post-install.sh | bash

Phase 2 — Recreer le cluster (T+45min a T+1h)​

# Sur galahad
pvecm create homelab

# Sur lancelot
pvecm add 192.168.1.18
# Mot de passe root de galahad requis

# Verifier
pvecm status

Phase 3 — Recreer les LXC (T+1h a T+2h)​

# Telecharger le template Debian 12
pveam update
pveam download local debian-12-standard_12.7-1_amd64.tar.zst

# Creer le conteneur
pct create 100 local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst \
--hostname dns-failover --memory 256 --cores 1 \
--net0 name=eth0,bridge=vmbr0,ip=192.168.1.30/24,gw=192.168.1.254 \
--unprivileged 1 --start 1

# Configurer
pct exec 100 -- bash -c "
apt update && apt install -y curl
# Installer AdGuard Home
curl -s -S -L https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/scripts/install.sh | sh -s -- -v
# Installer Tailscale
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up
"

# Restaurer la config AdGuard depuis le repo.
# Chemin corrige le 2026-09-04 : le renommage du 03/09 a deplace ce fichier
# vers adguard/adguard-prod-1/. L'ancien chemin etait MORT, et c'est l'etape
# qui restaure le resolveur DNS de secours — celle dont on a besoin quand tout
# le reste est a terre.
pct push 100 /mnt/ssd/config/adguard/adguard-prod-1/AdGuardHome.yaml \
/opt/AdGuardHome/AdGuardHome.yaml
pct exec 100 -- systemctl restart AdGuardHome

# Deployer le script health check (rpi_watchdog.sh)

Phase 4 — Reconfigurer le SSO et les integrations (T+2h a T+2h30)​

# Configurer Authelia OIDC pour Proxmox
pveum realm add authelia --type openid \
--issuer-url https://auth.home.gabin-simond.fr \
--client-id proxmox \
--client-key <SECRET_DEPUIS_VAULTWARDEN> \
--autocreate 1

# Token API pour Homepage
pveum user add homepage@pve
pveum aclmod / -user homepage@pve -role PVEAuditor
pveum user token add homepage@pve homepage --privsep=0

# Installer Tailscale sur les ZimaBoards
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up

Vérification post-restauration cluster​

CheckCommandeAttendu
Cluster OKpvecm statusQuorum OK, 2 nodes
LXC 100 UPpct status 100running
LXC 101 UPpct status 101running
LXC 102 UPpct status 102running
DNS secondairedig home.gabin-simond.fr @192.168.1.30192.168.1.28
Vaultwardencurl -sk https://vault.home.gabin-simond.frPage de login
OIDC ProxmoxLogin via Authelia sur l'UI webSession activé
Grafanacurl -sk https://logs.home.gabin-simond.frPage de login

Scénario 3 : Tout detruit (penny + cluster)​

Durée estimee : 4h + délai d'achat materiel

Prerequis absolus
  • YK1 ou YK2 avec son PIN (remplacé la clé USB)
  • age-plugin-yubikey installe sur la machine de travail (brew install age-plugin-yubikey)
  • Acces au master password Vaultwarden (mémoire ou coffre papier)
  • Acces au compte GitHub (pour cloner homelab-config) — ou clone local
  • Clés SSH pour acceder aux machines (clé FIDO2 sur la YubiKey, ou clé Ed25519 backup)

Strategie : paralleliser les restaurations​

Schéma agrandi
100 %

Ordre de restauration​

HeurepennyCluster (galahad + lancelot)
T+0Achat/récupération du matériel — Flasher DietPi sur la SD card—
T+0.5hOS, configs, SSD (Phase 2-3 du Scénario 1)Proxmox installer boot USB
T+1hDocker up, restauration volumes depuis R2galahad : post-install.sh, création cluster
T+1.5hAuthelia restaurée, Traefik + DNS UPlancelot : rejoint le cluster
T+2h—LXC 100 (dns-failover) UP, LXC 102 (vault) restauré depuis R2
T+2.5h—LXC 101 (logs) Grafana + Loki, SSO reconfiguré (OIDC Proxmox/Grafana/PBS/Pulse)
T+3hTests end-to-end (voir checklist ci-dessous)—
T+3.5hTailscale, vérification backup (restic snapshots)—
T+4hOPÉRATIONNEL—

Gestion de la dépendance d'amorçage (bootstrap)​

Problème : Pour configurer Traefik, il faut le token Cloudflare. Ce token est dans Vaultwarden. Mais Vaultwarden est sur le cluster qu'on est aussi en train de reconstruire.

Solution (3 niveaux de fallback) :

  1. App mobile Bitwarden : synchro offline, disponible immédiatement
  2. Clé USB chiffrée : contient le .env avec CF_DNS_API_TOKEN, TS_AUTHKEY, et tous les tokens
  3. Coffre papier : master password Vaultwarden → acces via un client Bitwarden sur un autre appareil, mais nécessité que le LXC 102 soit déjà restaure

En pratique : déchiffrer .env.enc avec la YubiKey (age-plugin-yubikey --identity > /tmp/yk-id.txt && SOPS_AGE_KEY_FILE=/tmp/yk-id.txt sops --input-type dotenv -d .env.enc > .env) pour démarrer penny independamment du cluster.


Scénario 4 : Corruption Authelia (DB ou clé OIDC)​

Durée estimee : 15-30 min | Impact : SSO indisponible, les services a ForwardAuth sont bloques

Vaultwarden et AdGuard restent accessibles

Vaultwarden n'utilisé pas Authelia. AdGuard utilisé ForwardAuth mais reste fonctionnel en acces direct (192.168.1.28:3000).

# Verifier les logs
docker logs authelia --tail 50

# Option A : Restaurer depuis le dernier backup
docker compose stop authelia
set -a; . /root/.restic-env; set +a # set -a auto-exporte : immune au prochain changement de backend
restic restore latest --target /tmp/restore --include "/mnt/ssd/config/authelia"
cp -a /tmp/restore/mnt/ssd/config/authelia/. /mnt/ssd/config/authelia/
rm -rf /tmp/restore
docker compose up -d authelia

# Option B : Regenerer les secrets (si backup corrompu aussi)
# ATTENTION : cela invalide TOUS les clients OIDC existants
cd /mnt/ssd/config
cp authelia/configuration.yml.example /mnt/ssd/config/authelia/configuration.yml
cp authelia/users_database.yml.example /mnt/ssd/config/authelia/users_database.yml

# Generer les 4 secrets + cle OIDC
for secret in jwt session storage hmac; do
echo "$secret: $(openssl rand -hex 32)"
done
openssl genrsa -out /mnt/ssd/config/authelia/oidc.pem 4096

# Regenerer les client secrets OIDC et les hasher
openssl rand -hex 32 # → secret clair pour le service
docker run --rm --entrypoint authelia authelia/authelia:latest \
crypto hash generate pbkdf2 --password "<SECRET_CLAIR>"
# Mettre le hash dans configuration.yml, le secret clair dans le service

# Regenerer le hash du mot de passe utilisateur
docker run --rm --entrypoint authelia authelia/authelia:latest \
crypto hash generate argon2 --password "<MOT_DE_PASSE>"

# Redemarrer et reconfigurer chaque client OIDC :
# - Proxmox : pveum realm modify authelia --client-key <NEW_SECRET>
# - Grafana : variable d'environnement GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET
# - PBS, Pulse, Forgejo, Outline : secret client dans la config de chacun
# (liste a jour : clients OIDC de services/authelia.md)
docker compose up -d authelia

Scénario 5 : Perte d'acces réseau (FAI, DNS externe, Cloudflare)​

Durée estimee : variable | Impact : pas d'acces distant, certificats TLS non renouvelables

# Diagnostic
ping 1.1.1.1 # Internet accessible ?
dig gabin-simond.fr @1.1.1.1 # DNS public fonctionne ?
curl -s https://api.cloudflare.com/ # Cloudflare accessible ?
tailscale status # Tailscale connecte ?

# Si Internet down :
# - Les services restent accessibles en LAN via IP directe
# - Les certificats TLS existants restent valides (90 jours Let's Encrypt)
# - Contacter le FAI (Free : 3244)

# Si Cloudflare down ou token revoque :
# - Generer un nouveau token API sur dashboard.cloudflare.com
# - Mettre a jour CF_DNS_API_TOKEN dans /mnt/ssd/config/.env
# - docker compose restart traefik

# Si Tailscale down :
# - Acces LAN uniquement
# - Re-authentifier : tailscale up --force-reauth
# - Si cle revoquee : login.tailscale.com > Machines > re-authorize

Scénario 6 : Perte de clé SSH ou YubiKey​

Impact : acces SSH compromis ou bloque

# Si cle SSH compromise (vol/fuite) :
# 1. Retirer la cle de TOUTES les machines
for host in homelab pve1 pve2; do
ssh gabins@$host "sed -i '/<FINGERPRINT>/d' ~/.ssh/authorized_keys"
done

# 2. Generer une nouvelle paire
ssh-keygen -t ed25519 -C "homelab-$(date +%Y%m%d)"

# 3. Deployer la nouvelle cle
for host in homelab pve1 pve2; do
ssh-copy-id -i ~/.ssh/id_ed25519.pub gabins@$host
done

# Si YubiKey perdue/defaillante :
# 1. Se connecter avec TOTP (backup) sur Authelia
# 2. Supprimer la YubiKey defaillante dans Authelia (user settings)
# 3. Enregistrer la YubiKey de remplacement
# 4. Revoquer les sessions actives par precaution

Contacts critiques​

ProviderRaisonContact
FAI (Free)Panne Internet3244
Cloudflare R2Acces backupsdashboard support
Proton MailAcces SMTP relay (token)proton.me/support
CloudflareZone DNS bloqueedashboard support
TailscaleTailnet issue[email protected]
YubiKey (Yubico)Clé defaillantesupport.yubico.com
DietPiBug OSgithub.com/MichaIng/DietPi/issues

Checklist de vérification finale​

A exécuter après toute restauration, quel que soit le scénario :

#!/bin/bash
# break-glass-verify.sh — a lancer depuis penny

echo "=== VERIFICATION POST-RESTAURATION ==="

echo -n "SSD monte rw : "
mount | grep -q "/mnt/ssd.*rw" && echo "OK" || echo "FAIL"

echo -n "ASPM off : "
cat /sys/module/pcie_aspm/parameters/policy | grep -q "\[off\]" && echo "OK" || echo "FAIL"

echo -n "DNS primaire : "
dig +short home.gabin-simond.fr @192.168.1.28 | grep -q "192.168.1.28" && echo "OK" || echo "FAIL"

echo -n "DNS secondaire : "
dig +short home.gabin-simond.fr @192.168.1.30 | grep -q "192.168.1.28" && echo "OK" || echo "FAIL"

echo -n "Traefik HTTPS : "
curl -sko /dev/null -w "%{http_code}" https://home.gabin-simond.fr | grep -qE "200|302" && echo "OK" || echo "FAIL"

echo -n "Authelia : "
curl -sko /dev/null -w "%{http_code}" https://auth.home.gabin-simond.fr | grep -qE "200|302" && echo "OK" || echo "FAIL"

echo -n "Vaultwarden : "
curl -sko /dev/null -w "%{http_code}" https://vault.home.gabin-simond.fr | grep -qE "200|302" && echo "OK" || echo "FAIL"

echo -n "Tailscale : "
tailscale status >/dev/null 2>&1 && echo "OK" || echo "FAIL"

echo -n "Docker containers : "
total=$(docker ps -q | wc -l)
healthy=$(docker ps --filter "status=running" -q | wc -l)
echo "$healthy/$total running"

echo -n "Backup R2 accessible : " # R2 depuis le 2026-05-11, plus B2
set -a; . /root/.restic-env; set +a # `source` seul n'exporte rien vers restic
restic snapshots >/dev/null 2>&1 && echo "OK" || echo "FAIL"

echo -n "Temperature : "
temp=$(vcgencmd measure_temp | grep -oP '[0-9.]+')
echo "${temp}C $([ $(echo "$temp < 70" | bc) -eq 1 ] && echo 'OK' || echo 'WARN')"

echo "=== FIN ==="

Tests de la procédure​

La procédure doit être testee au moins une fois par an sur un environnement jetable (spare RPi, VM locale, etc.).

DateScénarioDurée reelleProblèmes rencontresActions correctives
2026-04-13DR drill Vaultwarden7sAucun—
2026-09-06Reconstruction complète sur banc jetable (LXC 111, couches 1+2)490 s le cycle complet (reset 58 + couche 1 316 + couche 2 105 + verdict 11), rejeu idempotentLe banc n'est pas une Pi : couche 3 non eprouvableverify.yml passe (ok=11 failed=0) ; couche 3 appuyee sur penny en --check --diff
—Reconstruction complète penny (reelle, a froid)—A faire — voir les deux blocages mesures plus haut—
—Reconstruction cluster—A faire—
—Scénario 3 (tout detruit)—A faire—
—Rotation secrets Authelia—A faire—
Nouveau test = nouveau apprentissage

Documenter chaque obstacle et chaque ecart par rapport au temps estime. Mettre a jour ce document après chaque test.


Maintenance de ce document​

  • Fréquence de révision : a chaque changement d'architecture (nouveau service, nouveau LXC, migration)
  • Mise a jour clé USB : mensuelle (première semaine du mois)
  • Test DR : annuel minimum, trimestriel recommandé
  • Responsable : GabinSMD

Controle des chemins vivants — a rejouer apres chaque edition​

Cette page a deja porte trois chemins morts, dont un dans l'etape qui restaure le resolveur DNS de secours — celle dont on a besoin quand tout le reste est a terre. Un chemin mort dans un runbook ne se voit pas : il attend l'incident.

cd /mnt/ssd/homelab-doc

# Chemins que la procedure CREE elle-meme, ou qui n'existent qu'au milieu d'une
# sauvegarde. Les declarer ici est une decision qu'on relit ; les taire dans le
# grep serait un filtre qui grandit tout seul sans que personne le voie.
ATTENDUS_ABSENTS='^/mnt/ssd/doc-repo$|^/mnt/ssd/\.restic-staging|^/mnt/ssd/config\.avant-reprise'

grep -oE '(/mnt/ssd|/boot|/etc|/root|/var/lib|/usr/local)/[A-Za-z0-9_./-]+' \
docs/operations/break-glass.mdx \
| sed 's|[.,;)]*$||' | sort -u \
| grep -Ev "$ATTENDUS_ABSENTS" \
| while read -r p; do
[ -e "$p" ] || printf ' MORT %s\n' "$p"
done
echo " (aucune ligne = tous vivants)"

La liste de racines a ete elargie le 2026-09-06, apres qu'une relecture eut trouve un chemin mort dans le tableau de verification post-restauration — un fichier d'etat du moniteur qui n'a jamais existe sous ce nom. La version precedente du controle ne pouvait pas le voir : elle ne regardait que /mnt/ssd/config. Un controle qui n'inspecte qu'une portion de son objet ne dit rien du reste, et son silence ressemble a un succes.

Trois limites subsistent, a connaitre sinon il rassure a tort :

  • les racines sont une liste, donc un chemin ailleurs echappe encore. En ajouter une plutot que de faire confiance a sa memoire ;
  • ATTENDUS_ABSENTS est une liste aussi, et elle vieillit dans l'autre sens : une entree dont le chemin a disparu de la page ne protege plus rien, elle masque juste un peu plus. La relire quand on l'allonge ;
  • il ne distingue pas une prescription d'un recit. Un chemin mort cite pour raconter une correction passee sortirait en MORT a chaque execution. D'ou la regle appliquee deux fois dans cette page : dans un commentaire historique, nommer le chemin abandonne sans l'ecrire en entier.

Compte rendu du drill du 2026-08-26​

Premier exercice à suivre cette page. Non destructif : aucune LXC détruite, aucun service arrêté. Ce qui a été réellement exécuté est marqué comme tel.

Vérifié, et ça marche​

  • Les 15 fichiers que les phases 3 et 4 copient existent tous dans homelab-config — boot/, udev/, system/, network/, docker/, adguard/, traefik/, homepage/, et les deux .enc.
  • La commande de restauration corrigée fonctionne. Exécutée telle qu'écrite : les 4 variables attendues sont transmises, l'authentification à R2 passe, restic snapshots renvoie les 12 snapshots. C'est la preuve que le correctif set -a de ce matin est réel et pas théorique.
  • Une restauration complète a été faite depuis R2 vers un répertoire jetable, puis comparée aux fichiers vivants : 15 fichiers, aucun écart. Les données sont récupérables, pas seulement présentes.
  • La rétention est saine : mensuel, hebdomadaire, puis les sept derniers jours. Dernier snapshot du jour même.
  • restic check passe : 12 snapshots vérifiés, no errors were found. Le dépôt n'est pas seulement lisible, il est cohérent.

Trouvé pendant le drill​

  • La dépendance circulaire GitHub — fermée le soir même par la copie hors-ligne chiffrée (voir l'encadré en tête de page). Reste un geste physique : copier l'archive sur la clé USB du coffre.

  • restic était en 0.14.0, compilé en 2022 — monté en 0.19.1 le 26/08. Le paquet Debian de bookworm est figé en 0.14.0 : l'installation passe donc par le binaire officiel, posé via dpkg-divert pour qu'il occupe /usr/bin/restic (et non /usr/local/bin, que le PATH de cron ne contient pas) tout en survivant aux mises à jour du paquet. L'ancien binaire reste accessible en /usr/bin/restic.debian — retour arrière par dpkg-divert --remove --rename /usr/bin/restic.

    Vérifié avant de conclure : les 5 dépôts R2 listables, restic check sans erreur sur restic-finance, aucun verrou laissé, et les deux endroits où les scripts lisent la sortie texte (et non --json) donnent toujours ce qu'ils attendent — restic stats … | grep "Total Size" dans homelab_backup.sh et le restic ls --long du drill mensuel.

Deux pièges rencontrés pendant l'exercice​

Ne jamais piper restic dans head ou tail

Le SIGPIPE tue restic avant qu'il ne libère son verrou, et le dépôt reste verrouillé. C'est arrivé pendant ce drill même, et c'est la mécanique exacte qui avait laissé la sauvegarde Vaultwarden muette pendant six jours.

Rediriger vers un fichier, puis lire le fichier. Et avant de retirer un verrou, vérifier que son PID est mort — kill -0 <pid> — plutôt que de dévérouiller par réflexe.

restic snapshots --latest 1 n'est pas « le plus récent »

L'option renvoie le dernier snapshot par groupe (hôte + chemins + tags), pas le sommet absolu. Lu trop vite pendant ce drill, ça donne l'impression que les sauvegardes sont arrêtées depuis dix-sept jours alors qu'elles tournent normalement. Pour l'âge réel : lister tout et trier.

Ce que ce drill n'a pas prouvé​

La partie destructive. Recréer une LXC, réinstaller un OS, reconstruire le cluster : rien de tout ça n'a été exécuté. Les temps annoncés dans les objectifs de reprise restent donc des estimations, et le parcours complet n'est toujours pas chronométré.