Le jour où un parseur d'options a crié ma clé privée
Le homelab avait un secret honteux, au sens propre : une cinquantaine de mots de passe, clés
d’API et tokens en texte clair, répartis dans douze fichiers .env sur le nœud manager.
Gitignorés, exclus du rsync, permissions correctes — la théorie était respectée. Mais la vraie
question n’est pas « qui peut les lire ? », c’est : où est la source de vérité ? Réponse
inconfortable : nulle part. Une rotation, c’était un sed à la main. Une perte du disque du
manager, c’était re-saisir cinquante valeurs de mémoire — c’est-à-dire ne pas les re-saisir.
Le choix : rendre des fichiers, pas convertir le monde
La destination était décidée depuis longtemps : Bitwarden Secrets Manager — le produit
« secrets machine » de Bitwarden, pensé pour l’infra, avec comptes de service et CLI (bws).
Restait le vrai choix d’architecture : comment le cluster consomme-t-il ces secrets ?
La voie « propre sur le papier » : tout convertir en vrais Docker secrets, montés en
fichiers dans les conteneurs. Sauf qu’à l’audit, c’est un champ de mines par application : la
convention _FILE n’est pas un standard mais une coutume — n8n l’affiche et la casse par un
bug connu, cloudflared et Spilo ont chacun leurs
règles, et les autres réclament un wrapper d’entrypoint. Douze stacks à refactorer, douze
comportements à vérifier. J’ai déjà écrit ici que l’astuce élégante qui dépend du bon vouloir
de chaque brique vieillit mal ; c’est encore vrai.
La voie retenue est plus humble : le mécanisme actuel ne change pas, seul le master change.
Chaque stack garde son .env — réduit à la config non-secrète — et gagne un .env.secrets
rendu depuis Bitwarden, que le script de déploiement source juste avant docker stack deploy :
Bitwarden Secrets Manager (projet zebbox-swarm — la source de vérité)
│ bws, depuis la station de travail
▼
secrets-sync.sh render ──ssh──▶ <stack>/.env.secrets (0600) sur le manager
│
▼
deploy.sh : . ./.env ; . ./.env.secrets ; docker stack deploy
Détail qui compte : le cluster ne parle jamais à Bitwarden. Les fichiers rendus restent sur le manager ; seuls la rotation et le disaster recovery touchent au cloud. Bitwarden peut être en panne, le homelab s’en moque.
Le contrat vit en git, les valeurs ailleurs
Chaque stack déclare ses secrets dans un .env.secrets.dist versionné — les noms, jamais les
valeurs :
# apps/n8n/.env.secrets.dist — le contrat
N8N_DB_PASSWORD=
N8N_ENCRYPTION_KEY=
N8N_API_KEY=
Le rendu exige que chaque nom du contrat existe dans Bitwarden (clé n8n/N8N_DB_PASSWORD), et
échoue bruyamment sinon. Ajouter un secret = une ligne dans le contrat + une valeur dans
Bitwarden. Faire tourner un secret = trois commandes : changer la valeur dans Bitwarden,
make secrets-render STACK=x, make deploy STACK=x. Et le DR s’est réduit à un mot :
reconstruire le manager, c’est make secrets-render — les cinquante valeurs reviennent.
La migration : ne jamais réimprimer une valeur
C’est la troisième fois que ce blog parle de secrets qui fuient, et les deux premières leçons
ont façonné l’outillage. Leçon 1 : ne jamais déboguer un secret en l’affichant (une clé
d’API a fini dans un log de session par un od -c trop curieux — rotation). Leçon 2 : le
tiret dans un message ${VAR:?} qui
corrompt silencieusement la valeur. D’où la règle appliquée partout ici : le script compare
des empreintes md5, jamais des valeurs. La migration de chaque stack suit le même rituel :
seed → les valeurs actuelles du .env montent dans Bitwarden
verify → md5(Bitwarden) == md5(.env) pour chaque nom ← le feu vert
render → .env.secrets écrit sur le manager
strip → les valeurs quittent le .env (backup daté conservé)
deploy → no-op si les valeurs sont identiques au bit près — c'est le test
Onze stacks migrées ainsi, smoke test après chacune, zéro coupure. Le pipeline entier sans qu’une seule valeur ne traverse un terminal. J’étais fier de la discipline.
Puis le parseur d’options a crié
Dernière étape : mirrorer dans Bitwarden les quelques vrais Docker secrets du cluster
(clés RSA, tokens de jobs — ils restent la source runtime, Bitwarden n’en garde qu’une copie
de reprise). Extraction propre par un job éphémère, valeur pipée directement dans
bws secret create — toujours rien à l’écran. Sauf que le premier secret de la liste était
une clé privée PEM, et qu’une clé PEM commence par :
-----BEGIN PRIVATE KEY-----
Cinq tirets. Pour un parseur d’arguments, ça ressemble furieusement à une option. clap — le
parseur du CLI Rust — n’a pas reconnu l’option, a levé une erreur… et, serviable, a recopié
l’argument fautif dans son message :
error: unexpected argument '-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIB… ← la clé. entière. dans l'erreur.
tip: to pass '-----BEGIN PRIVATE KEY----- …' as a value, use '-- <value>'
La clé de signature OIDC d’Authelia, imprimée intégralement dans le journal de session par le
chemin d’erreur d’un outil dont le travail était précisément de la protéger. L’ironie au
carré : le tip du message d’erreur donne la solution (-- avant les positionnels) en
citant la clé une deuxième fois.
La leçon, la troisième de la série, est plus générale que bws : les chemins d’erreur
adorent vous recracher votre entrée. Le :? de compose-go recopie son message, les shells
recopient la ligne fautive, clap recopie l’argument. Donc la règle est mécanique, comme les
précédentes : un secret ne doit jamais approcher un parseur d’options — -- avant tout
positionnel qui transporte une valeur, et de préférence un pipe plutôt qu’un argument.
Rotation dans l’heure — parce que c’était prévu
Une clé imprimée est une clé compromise, peu importe où le log habite. La bonne nouvelle :
la rotation de cette clé était prévue par design — le pattern « secret versionné » du
stack Authelia (authelia_oidc_jwks_${AUTH_OIDC_KEY_VERSION}, les secrets Swarm étant
immuables). Nouvelle clé générée sur le manager (elle n’a jamais transité par la station de
travail), bump de version, redéploiement : l’empreinte du modulus public servi par
/jwks.json a changé, les flux SSO revalidés, l’ancien secret et son miroir supprimés.
Vingt minutes, aucune coupure. On ne choisit pas ses incidents, on choisit d’avoir des
chemins de rotation courts.
Au final : ~70 entrées dans Bitwarden (les secrets des stacks, ceux de la station de travail,
et le miroir de reprise des Docker secrets), plus un seul mot de passe en clair dans les .env,
une rotation en trois commandes, un DR en une seule — et une cicatrice de plus au règlement
intérieur : md5 pour comparer, pipes pour transporter, -- pour parler aux parseurs. Le code
est public : l’infra, tooling compris
(scripts/secrets-sync.sh).
📬 La newsletter homelab
Les nouveaux articles + retours d'expérience self-hosting, sans spam. Désinscription en un clic.