← tous les articles

Le jour où un parseur d'options a crié ma clé privée

14 juillet 2026 · 6 min de lecture

securitesecretsbitwardendocker-swarmconfig-as-code

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.