From d619f165f0623cf5a85b55e6b15837ed4f49d61e Mon Sep 17 00:00:00 2001 From: Bertrand Benjamin Date: Wed, 12 Aug 2026 16:38:34 +0200 Subject: [PATCH 1/2] feat(secrets): chiffrement des secrets avec SOPS + age MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Met en place la mécanique de gestion des secrets du dépôt : - .sops.yaml : deux périmètres séparés par acteur. Les stacks/*/secrets.env.yaml sont déchiffrables par le compte de déploiement de chioggia ET l'admin ; tout le reste par l'admin seul. Une compromission du serveur ne donne donc pas accès aux credentials OVH, qui restent gérés par pass. - .gitignore : tout .env est traité comme un accident. La configuration non sensible de chaque stack vit dans un fichier `env_file` versionné. - README : création des clés, emplacements, recettes et pièges. Les secrets sont injectés à la volée par `sops exec-env` : aucun fichier déchiffré ne touche le disque. Co-Authored-By: Claude Opus 5 --- .gitignore | 22 +++++++++++++ .sops.yaml | 53 +++++++++++++++++++++++++++++++ README.md | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 .sops.yaml diff --git a/.gitignore b/.gitignore index 66ee655..e6bbab8 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,25 @@ terraform.rc # Optional: ignore plan files saved before destroying Terraform configuration # Uncomment the line below if you want to ignore planout files. # planout +**/.claude + +# ============================================ +# Secrets — voir .sops.yaml +# ============================================ +# Les secrets vivent UNIQUEMENT dans des stacks/*/secrets.enc.yaml chiffrés, +# consommés à la volée par `sops exec-env`. Aucun secret déchiffré ne doit +# exister sur le filesystem : les règles ci-dessous sont une ceinture de +# sécurité, pas un mode de fonctionnement. +.env +*.env +*.dec.yaml +*.decrypted.* + +# La configuration NON sensible de chaque stack vit dans un fichier nommé +# `env_file` (sans extension) : il échappe donc volontairement aux règles +# ci-dessus et reste versionné en clair. + +# Clés privées age (ne doivent jamais approcher le dépôt) +*.agekey +age-key.txt +keys.txt diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..a9137cf --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,53 @@ +# ============================================================ +# Politique de chiffrement SOPS +# ============================================================ +# Deux périmètres, séparés par ACTEUR — c'est le garde-fou principal +# de ce dépôt : +# +# 1. stacks/*/secrets.env.yaml +# Déchiffrables par chioggia (déploiement automatique, sans humain) +# ET par l'admin. YAML PLAT uniquement (CLE: valeur) : `sops exec-env` +# ne sait pas exporter une structure imbriquée. +# +# 2. tout le reste +# Déchiffrables par l'admin UNIQUEMENT. +# Conséquence voulue : une compromission de chioggia ne donne PAS +# accès aux credentials OVH / registrar. Sans cette séparation, +# prendre le serveur reviendrait à prendre le domaine (donc les MX, +# le SPF, et la capacité d'émettre des certificats). +# +# Les credentials OpenTofu (OVH, AdGuard) restent gérés par `pass` : +# ils sont utilisés en interactif depuis le poste admin, jamais par une +# machine. Voir dns/ovh.tf et dns/local.tf. +# +# ------------------------------------------------------------ +# Générer une clé — une par machine, seule la PUBLIQUE arrive ici : +# +# poste admin : age-keygen | pass insert -m infra/age-key +# (publique : pass show infra/age-key | age-keygen -y) +# serveur : mkdir -p ~/.config/sops/age +# (umask 077; age-keygen -o ~/.config/sops/age/keys.txt) +# age-keygen -y ~/.config/sops/age/keys.txt +# +# Fournir la clé privée à sops : +# poste : export SOPS_AGE_KEY="$(pass show infra/age-key)" +# serveur : ~/.config/sops/age/keys.txt est l'emplacement par défaut, +# lu sans aucune variable d'environnement +# +# Rappel : ajouter une clé ici ne rechiffre RIEN. +# sops updatekeys -y stacks/*/secrets.env.yaml +# ------------------------------------------------------------ + +keys: + - &lafrite_combava age18m2zl3pwgw97djshzcf4wmhrqxksl4erqse6edkpxpkg5rcz0ujs8u4c7s + - &waha_chioggia age1fh70nny5hzz8a8g9077kgfca0lhlh3wsjf4fjrv4klx5ucdtpdmsazuqdj + +creation_rules: + - path_regex: ^stacks/.*/secrets\.env\.yaml$ + age: + - *lafrite_combava + - *waha_chioggia + + - path_regex: .* + age: + - *lafrite_combava diff --git a/README.md b/README.md index bbe65f2..07188c3 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,99 @@ Configuration Terraform pour gérer l'infrastructure domestique. +## Secrets (SOPS + age) + +Les secrets sont chiffrés **dans** le dépôt. Rien de déchiffré ne doit jamais toucher +le disque : `sops exec-env` injecte les valeurs en variables d'environnement le temps +d'une commande. + +### Créer une clé + +Une clé par machine. La privée ne quitte jamais sa machine ; seule la **publique** +est recopiée dans `.sops.yaml`. + +```bash +# poste admin — la privée va dans pass, jamais sur le disque +age-keygen | pass insert -m infra/age-key +pass show infra/age-key | age-keygen -y # publique -> .sops.yaml + +# serveur (chioggia, ou toute nouvelle machine) — sous le compte qui déploie +# les stacks, PAS root : sops lit ce chemin par défaut, sans aucune variable +# d'environnement à exporter +mkdir -p ~/.config/sops/age +(umask 077; age-keygen -o ~/.config/sops/age/keys.txt) +age-keygen -y ~/.config/sops/age/keys.txt # publique -> .sops.yaml +``` + +`age-keygen` écrit la clé privée sur stdout (trois lignes, dont `# public key:`) +et un rappel de la publique sur stderr — c'est bien la privée seule qui entre dans +`pass`. Vérifier avec `pass show infra/age-key`. + +Ajouter ensuite la publique au bon `creation_rules` de `.sops.yaml`, **puis +rechiffrer l'existant** — sans quoi la nouvelle machine ne pourra rien lire : + +```bash +sops updatekeys -y stacks/*/secrets.env.yaml +``` + +### Où sont les clés + +| Machine | Clé privée | Comment sops la trouve | +|---|---|---| +| Poste admin | dans `pass`, sous `infra/age-key` | `export SOPS_AGE_KEY="$(pass show infra/age-key)"` | +| chioggia | `~/.config/sops/age/keys.txt` (600) du compte de déploiement | emplacement par défaut : rien à exporter | + +Sur chioggia, le compte qui déploie est aussi celui qui déchiffre. Il est membre du +groupe `docker` et n'a donc jamais besoin de `sudo` — ni pour `git pull`, ni pour +`docker compose`, ni pour lire sa clé. + +`.sops.yaml` définit qui déchiffre quoi : chioggia n'a accès qu'aux +`stacks/*/secrets.env.yaml`. Les credentials OVH et AdGuard restent gérés par `pass` +(voir `dns/`) et sont hors de sa portée — une compromission du serveur ne doit pas +donner le domaine. + +### Recettes + +```bash +# éditer un secret (rechiffré à la sortie de l'éditeur) +sops edit stacks/vault.opytex.org/secrets.env.yaml + +# créer les secrets d'une nouvelle stack — le chemin n'a pas besoin d'exister, +# rien n'est écrit en clair +sops edit stacks//secrets.env.yaml + +# lire sans éditer +sops -d stacks/vault.opytex.org/secrets.env.yaml + +# déployer, depuis le dossier de la stack sur chioggia +sops exec-env secrets.env.yaml 'docker compose up -d' + +# après TOUTE modification de .sops.yaml (nouvelle machine, clé retirée). +# Accepte plusieurs fichiers malgré l'aide qui annonce `file` au singulier. +# Sans -y, sops demande une confirmation par fichier — mais montre le diff +# des destinataires, utile pour vérifier quelle creation_rules s'applique. +sops updatekeys -y stacks/*/secrets.env.yaml +``` + +Le format est du **YAML plat** (`CLE: valeur`) : `exec-env` ne sait pas exporter une +structure imbriquée. + +### Les pièges à connaître + +- **`sops updatekeys` n'est pas automatique.** Modifier `.sops.yaml` ne rechiffre rien ; + les fichiers existants restent lisibles par les anciennes clés seulement. +- **Une clé illisible produit un message trompeur.** Mauvais propriétaire ou mauvais + `chmod` sur `keys.txt` donne `Recovery failed because no master key was able to + decrypt the file` — pas un mot sur les permissions. Vérifier `ls -l` sur la clé + avant de suspecter le chiffrement. +- **Les composes déclarent `VAR: ${VAR:?}`.** Un `docker compose up` lancé sans + `sops exec-env` échoue immédiatement, au lieu de recréer les conteneurs avec des + valeurs vides. Ne pas « simplifier » en `VAR: ${VAR}`. +- **Perdre les deux copies de la clé age = perdre tous les secrets du dépôt.** + Celle du poste vit dans `pass`, à sauvegarder hors ligne. Celle de chioggia est dans + un répertoire personnel : elle n'est PAS couverte par le backup tant qu'elle n'est + pas ajoutée à `MANUAL_DIRECTORIES` dans `backup.conf`. + ## DNS ### Gestion des DNS -- 2.49.1 From 4aacd902f157bd4f42fa9e2cf3ecd478bc79662c Mon Sep 17 00:00:00 2001 From: Bertrand Benjamin Date: Wed, 12 Aug 2026 16:38:34 +0200 Subject: [PATCH 2/2] feat(vault): migre vault.opytex.org vers stacks/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Première stack migrée vers le nouveau modèle. Le nom du dossier est conservé à l'identique : il détermine le nom du projet Compose (vaultopytexorg) et donc le préfixe des volumes, ce qui permet de reprendre les données existantes. - env_file : les 22 variables non sensibles, en clair et diffables - secrets.env.yaml : ADMIN_TOKEN et SSO_CLIENT_SECRET, chiffrés - env_file: .env supprimé ; les deux secrets passent par ${VAR:?} pour échouer franchement si `sops exec-env` est oublié Corrections au passage : - priority=10 sur le router principal. Sans elle Traefik la déduisait de la longueur de la règle (23), contre 26 sur le router /admin : trois caractères de marge avant que /admin ne perde le forward auth. - backup.enable sur le volume, qui n'était couvert par aucune sauvegarde. À compléter dans MANUAL_VOLUMES : un label n'agit pas sur un volume déjà créé. - version d'image figée (1.35.7) au lieu de latest, détectable par Renovate - security_opt: no-new-privileges Co-Authored-By: Claude Opus 5 --- stacks/vault.opytex.org/docker-compose.yml | 86 ++++++++++++++++++++++ stacks/vault.opytex.org/env_file | 54 ++++++++++++++ stacks/vault.opytex.org/secrets.env.yaml | 26 +++++++ 3 files changed, 166 insertions(+) create mode 100644 stacks/vault.opytex.org/docker-compose.yml create mode 100644 stacks/vault.opytex.org/env_file create mode 100644 stacks/vault.opytex.org/secrets.env.yaml diff --git a/stacks/vault.opytex.org/docker-compose.yml b/stacks/vault.opytex.org/docker-compose.yml new file mode 100644 index 0000000..e684d16 --- /dev/null +++ b/stacks/vault.opytex.org/docker-compose.yml @@ -0,0 +1,86 @@ +# ============================================================ +# Vaultwarden — vault.opytex.org +# ============================================================ +# Déploiement : +# sops exec-env secrets.env.yaml 'docker compose up -d' +# +# Le nom du dossier détermine le nom du projet Compose (`vaultopytexorg`, +# les points étant retirés) et donc le préfixe des volumes. Le conserver +# à l'identique est ce qui permet de reprendre les données existantes. +# +# Configuration non sensible : env_file (versionné, en clair). +# Secrets : secrets.env.yaml (chiffré), injectés par `sops exec-env`. + +services: + vaultwarden: + # Version en dur, jamais via une variable : c'est ce qui permet à Renovate + # de détecter les montées de version et d'ouvrir une PR. + image: vaultwarden/server:1.35.7 + container_name: vaultwarden + restart: unless-stopped + security_opt: + - no-new-privileges:true + volumes: + - vaultwarden_data:/data + env_file: + - env_file + environment: + # Les deux seules valeurs sensibles. `${VAR:?}` fait échouer le + # démarrage si `sops exec-env` a été oublié, au lieu de recréer le + # conteneur avec des valeurs vides. + # + # Régénérer le token admin en hash argon2 : + # docker run --rm -it vaultwarden/server:1.35.7 /vaultwarden hash --preset owasp + ADMIN_TOKEN: ${ADMIN_TOKEN:?} + SSO_CLIENT_SECRET: ${SSO_CLIENT_SECRET:?} + networks: + - traefik-proxy + - smtp + labels: + - traefik.enable=true + - traefik.docker.network=traefik-proxy + + # --- Router principal (UI + API clients Bitwarden) --- + # `priority` explicite : sans elle, Traefik la déduit de la longueur de + # la règle — 23 pour Host(`vault.opytex.org`), contre 26 forcés sur le + # router /admin. Trois caractères de marge seulement : un domaine plus + # long ferait passer ce router devant, et /admin perdrait le forward auth. + - traefik.http.routers.vaultwarden.entrypoints=web-secure + - traefik.http.routers.vaultwarden.rule=Host(`vault.opytex.org`) + - traefik.http.routers.vaultwarden.priority=10 + - traefik.http.routers.vaultwarden.tls.certresolver=letsencrypt + - traefik.http.routers.vaultwarden.service=vaultwarden + - traefik.http.routers.vaultwarden.middlewares=crowdsec@file + + # --- /admin : CrowdSec + forward auth Authentik --- + # Porte de secours si le SSO tombe (SSO_ONLY=true côté env_file). + - traefik.http.routers.vaultwarden-admin.entrypoints=web-secure + - traefik.http.routers.vaultwarden-admin.rule=Host(`vault.opytex.org`) && PathPrefix(`/admin`) + - traefik.http.routers.vaultwarden-admin.priority=26 + - traefik.http.routers.vaultwarden-admin.tls.certresolver=letsencrypt + - traefik.http.routers.vaultwarden-admin.service=vaultwarden + - traefik.http.routers.vaultwarden-admin.middlewares=crowdsec@file,authentik@file + + # --- Callback Authentik (outpost) --- + - traefik.http.routers.vaultwarden-outpost.entrypoints=web-secure + - traefik.http.routers.vaultwarden-outpost.rule=Host(`vault.opytex.org`) && PathPrefix(`/outpost.goauthentik.io/`) + - traefik.http.routers.vaultwarden-outpost.priority=25 + - traefik.http.routers.vaultwarden-outpost.tls.certresolver=letsencrypt + - traefik.http.routers.vaultwarden-outpost.service=vaultwarden-outpost-svc + + - traefik.http.services.vaultwarden.loadbalancer.server.port=80 + - traefik.http.services.vaultwarden-outpost-svc.loadbalancer.server.url=http://authentik:9000 + +volumes: + # ATTENTION : les labels de volume ne s'appliquent qu'à la CRÉATION. Le volume + # `vaultopytexorg_vaultwarden_data` existe déjà et n'héritera PAS de ce label : + # il faut l'ajouter à MANUAL_VOLUMES dans backup.conf. + vaultwarden_data: + labels: + backup.enable: "true" + +networks: + traefik-proxy: + external: true + smtp: + external: true diff --git a/stacks/vault.opytex.org/env_file b/stacks/vault.opytex.org/env_file new file mode 100644 index 0000000..0e7f016 --- /dev/null +++ b/stacks/vault.opytex.org/env_file @@ -0,0 +1,54 @@ +# ============================================================================= +# VAULTWARDEN — configuration NON sensible +# ============================================================================= +# Versionné en clair, volontairement : cette configuration doit rester +# relisible et diffable sans déchiffrer quoi que ce soit. +# +# Les deux valeurs sensibles (ADMIN_TOKEN, SSO_CLIENT_SECRET) sont dans +# secrets.env.yaml et injectées par `sops exec-env`. +# +# Ce fichier ne contient QUE ce que le conteneur consomme. Ce qui pilote le +# compose lui-même — version d'image, règles Traefik — est écrit en dur dans +# docker-compose.yml : `env_file:` n'alimente pas l'interpolation ${...}, et +# une version en dur est directement détectée par Renovate. +# ============================================================================= + +# --- Domaine --- +DOMAIN=https://vault.opytex.org + +# --- Inscriptions --- +# false = seuls les comptes invités via admin peuvent s'inscrire +SIGNUPS_ALLOWED=false +SIGNUPS_VERIFY=true + +# --- SSO OpenID Connect (Authentik) --- +# SSO_ONLY=true : désactive le login natif Bitwarden, tout passe par Authentik +SSO_ENABLED=true +SSO_ONLY=true +SSO_AUTHORITY=https://sso.opytex.org/application/o/vaultwarden/ +SSO_CLIENT_ID=KGGkButSscxfnbo6KDAH5aj7VHMOcvwtELSehGPl +SSO_SCOPES=email profile offline_access +SSO_PKCE=true + +# Pour faire marcher avec le sso +SSO_ORGANIZATIONS_INVITE=true +SSO_ORGANIZATIONS_ALL_COLLECTIONS=true + +# --- Base de données --- +# SQLite dans le volume vaultwarden_data. Pas de service db, pas de dump : +# la sauvegarde repose entièrement sur celle du volume. +DATABASE_URL=/data/db.sqlite3 + +# --- SMTP (relais interne, réseau `smtp`) --- +SMTP_HOST=postfix +SMTP_FROM=noreply@opytex.org +SMTP_FROM_NAME=Vaultwarden +SMTP_SECURITY=off +SMTP_PORT=25 + +# --- Logs & sécurité --- +LOG_LEVEL=warn +EXTENDED_LOGGING=true +IP_HEADER=X-Forwarded-For +LOGIN_RATELIMIT_MAX_BURST=5 +LOGIN_RATELIMIT_SECONDS=60 diff --git a/stacks/vault.opytex.org/secrets.env.yaml b/stacks/vault.opytex.org/secrets.env.yaml new file mode 100644 index 0000000..de0883e --- /dev/null +++ b/stacks/vault.opytex.org/secrets.env.yaml @@ -0,0 +1,26 @@ +ADMIN_TOKEN: ENC[AES256_GCM,data:PohQt8NlNAlUeWMdvqDdkUmHKJoqkLFE5bF3gZPvvDzxtaLWvtIdnnSkf/3mYJR+CSWjiObM6HgjhZ/WjzeR4324/bWVy5xXwH+o1TGXzXLtCruMrLPZFZDC99tmORa571gZ0DluRPI5EOEcYYQx3AQqggTVow==,iv:o4K3ydAFhFEo/aGhZSYdAWnJCqRGgg9p9zcbhPxzvvM=,tag:zH2t/bxKYi+C/XXU/j8z6Q==,type:str] +SSO_CLIENT_SECRET: ENC[AES256_GCM,data:elGebRAUOJemc0DGyCMS8f2+qU5CJZYW1Cu51936m7XNFy0FQZltJrtpSJm952UHtuoYvN9/TGKXFABEPs9R3vfb9EJ3jgV+Jw/Lvwa+HI2UqwQsxRPsJ11ihRAQuQRmIia+nzy0cH2/Kt8sNRxQhw/tR/Up70J10Y8MoXm5jCg=,iv:6D2YpUI0GlqsPDI5BeAu8hFv7LWJDAfJVDmXay14/38=,tag:fxxn9Qi3oJt7qBciG+Ct3g==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOS2lpaE5RNlZmcjNLQWFh + YzRJalZaRC9TRVFnUlNGc21WdTJGVkpUMlFJCjNHVFp1V3N1b3Nla0Z2Q1NuaHZQ + NXpHSnFTTFpvVVhhakh6bnBFTHBCUzgKLS0tIEQ4NzNha1NaV3cyNTZST2JwaXQw + UWVCWWIxM2VkbkhTTWNGVERXV29yZTgKpkNFQGeW9XoS274YMeGEOUgLh1z6oT5k + hwQMD/9EYv2npMKGEPQlrKwIUhJly8m+t2TOjUp/1YS37Bd8MbNbBA== + -----END AGE ENCRYPTED FILE----- + recipient: age18m2zl3pwgw97djshzcf4wmhrqxksl4erqse6edkpxpkg5rcz0ujs8u4c7s + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxTmhYWloyd0dWZlFJdmpV + KzI4WjhQTVBjS3R3NzRvL3B6Z2xyM09qOHlVCmlST0hOenJUb3lSN0lFMk9pOU11 + aGsvL0JWV0hJanZkN0t2VzFSWUFlT1UKLS0tIGdpY05pcmVqSllqTFk2MmJvZS9I + cHRabXdZTjhMeUFObHRyNGN3TjFxZWsKliW1EpTLHAk1INr177ezaMgyYg1gLV8B + X5QLAU85NtxNyb6GhYBHJprdM6Cw/RWXQoyfFig7FophJKzzXRHqdA== + -----END AGE ENCRYPTED FILE----- + recipient: age1fh70nny5hzz8a8g9077kgfca0lhlh3wsjf4fjrv4klx5ucdtpdmsazuqdj + lastmodified: "2026-08-12T14:10:42Z" + mac: ENC[AES256_GCM,data:URzoDTyKu6YtfV8fbKIK1hbyTBn1SlFOsGhZFekS3IAMS7zKAYJISFczpCvValQdDVcngYBfnPP+lgmXSxQHe6vVdo9TplCI0HIBNEtIqLJwPMXlc6hw9XTUvDnSpoB32mgIOtTNQeIlZ2T2EcuyCL44i202MmMuKFmafPUdEY4=,iv:OlaLkzxbN7isHawJ6iwFEG2L6uXU/WL6fzo9fDZ0qlI=,tag:CK2Dt2MME88Sz59tGzWi6w==,type:str] + unencrypted_suffix: _unencrypted + version: 3.13.3 -- 2.49.1