From 620ecfa0a189fa1723aba5e6defa204551b0fa7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 18:50:25 +0000 Subject: [PATCH] =?UTF-8?q?G=C3=A9n=C3=A8re=20le=20jeton=20de=20l'updater?= =?UTF-8?q?=20au=20lieu=20de=20le=20faire=20saisir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le bouton « Appliquer maintenant » demandait de choisir une valeur au hasard, de la coller dans le .env du serveur et de relancer la pile. C'est une contrainte pour rien : ce jeton n'autorise qu'une demande de redémarrage entre deux conteneurs de la même machine. L'entrypoint le génère désormais au premier démarrage et le conserve dans le volume de configuration, comme les autres secrets. Watchtower remplace la valeur de WATCHTOWER_HTTP_API_TOKEN par le contenu du fichier quand elle désigne un fichier existant : les deux conteneurs partagent donc le même jeton sans que personne n'ait de valeur à saisir. Renseigner la variable dans le .env continue d'imposer une valeur. Le conteneur `updater-token`, lancé par le même profil, exécute l'entrypoint puis s'arrête : il garantit que le fichier existe avant que l'updater ne le lise, sans faire dépendre l'updater de la santé de l'application, dont il est justement le moyen de rétablissement. Deux correctifs découverts en vérifiant le tout sur une pile réelle : - Watchtower 1.7.1 s'adresse au démon Docker en API 1.25, que les moteurs récents refusent (« client version 1.25 is too old », minimum 1.40) : aucune mise à jour n'avait lieu. DOCKER_API_VERSION corrige ce point. - Les fichiers temporaires des secrets étaient nommés d'après le PID, or tous les conteneurs démarrent leur entrypoint en PID 1 : deux conteneurs lancés en même temps sur un volume vierge s'écrasaient mutuellement et pouvaient retenir des secrets différents. mktemp et une création par lien dur les font converger — vérifié avec six conteneurs simultanés. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014SfQYBU4xXTeSEHKhHQXdD --- .env.example | 15 +++--- compose.yaml | 42 ++++++++++++++-- docker/entrypoint.sh | 71 +++++++++++++++++++++------ docs/DOCKER.md | 31 +++++++----- src/components/update-status-card.tsx | 16 +++--- src/lib/services/updates.ts | 12 ++--- 6 files changed, 138 insertions(+), 49 deletions(-) diff --git a/.env.example b/.env.example index f7e7d36..029cb12 100644 --- a/.env.example +++ b/.env.example @@ -59,14 +59,17 @@ COMPOSE_PROFILES="autoupdate" # Intervalle entre deux contrôles, en secondes (3600 = toutes les heures). WATCHTOWER_POLL_INTERVAL="3600" -# Secret partagé entre le conteneur de l'application et le conteneur `updater`, -# tous deux sur cette machine. Il autorise l'application à demander un -# redémarrage immédiat depuis Configuration → Version et mises à jour ; il n'a -# aucun rapport avec l'accès à l'image, qui est publique. Une valeur au hasard -# suffit, laisser vide désactive simplement le bouton. -# Générer : openssl rand -base64 32 +# Rien à renseigner : le jeton qui autorise le bouton « Appliquer maintenant » +# de Configuration → Version et mises à jour est généré au premier démarrage et +# conservé dans le volume de configuration, où le conteneur `updater` le relit. +# Une valeur ici en impose une à la place, ce dont personne n'a besoin. WATCHTOWER_HTTP_API_TOKEN="" +# Version de l'API Docker utilisée par le conteneur `updater`. La valeur par +# défaut convient de Docker 20.10 à aujourd'hui ; sans elle, Watchtower parle +# une version que les moteurs récents refusent. +DOCKER_API_VERSION="1.41" + # Dépôt et branche surveillés par l'indicateur de version de l'application. # Mettre UPDATE_CHECK_ENABLED à "false" pour supprimer tout appel sortant. UPDATE_REPOSITORY="flocom/APEL-manager" diff --git a/compose.yaml b/compose.yaml index 6b730b4..408ad54 100644 --- a/compose.yaml +++ b/compose.yaml @@ -28,6 +28,8 @@ x-app-environment: &app-environment # tourne et à quelle cadence, sans dupliquer le réglage. COMPOSE_PROFILES: ${COMPOSE_PROFILES:-} WATCHTOWER_POLL_INTERVAL: ${WATCHTOWER_POLL_INTERVAL:-3600} + # Laissée vide, l'entrypoint génère le jeton au premier démarrage et le + # conserve dans le volume de configuration, d'où l'`updater` le relit. WATCHTOWER_HTTP_API_TOKEN: ${WATCHTOWER_HTTP_API_TOKEN:-} UPDATE_REPOSITORY: ${UPDATE_REPOSITORY:-flocom/APEL-manager} UPDATE_CHANNEL: ${UPDATE_CHANNEL:-main} @@ -128,6 +130,25 @@ services: - no-new-privileges:true stop_grace_period: 30s + # Prépare le jeton partagé avec l'`updater` avant que celui-ci ne démarre : + # l'entrypoint de l'image génère et conserve les secrets manquants, puis ce + # conteneur s'arrête aussitôt. Il existe pour que l'`updater` n'ait pas à + # attendre que l'application soit saine — il est justement ce qui permet de + # rétablir une application cassée. + updater-token: + image: ${APEL_IMAGE:-ghcr.io/flocom/apel-manager:latest} + profiles: ["autoupdate"] + restart: "no" + init: true + environment: + <<: *app-environment + SKIP_MIGRATIONS: "1" + command: ["true"] + volumes: + - app_config:/app/data/config + security_opt: + - no-new-privileges:true + # Mise à jour automatique. Surveille l'image publiée sur GHCR et recrée `app` # et `scheduler` dès qu'une nouvelle version paraît ; l'entrypoint applique # ensuite les migrations. Seuls les conteneurs portant le label @@ -147,10 +168,18 @@ services: WATCHTOWER_CLEANUP: "true" WATCHTOWER_INCLUDE_RESTARTING: "true" WATCHTOWER_POLL_INTERVAL: ${WATCHTOWER_POLL_INTERVAL:-3600} - # Déclenchement à la demande depuis Configuration. Sans jeton, l'API - # reste fermée et le bouton n'apparaît pas dans l'application. + # Watchtower s'adresse au démon Docker en version d'API 1.25 par défaut, + # que les moteurs récents refusent (« client version 1.25 is too old ») : + # sans cette valeur, aucune mise à jour n'a lieu. 1.41 est acceptée par + # tous les démons depuis Docker 20.10. + DOCKER_API_VERSION: ${DOCKER_API_VERSION:-1.41} + # Déclenchement à la demande depuis Configuration. Watchtower remplace la + # valeur par le contenu du fichier quand elle désigne un fichier existant + # : le jeton est donc celui que l'application a généré, sans que personne + # ait de valeur à choisir. Renseigner WATCHTOWER_HTTP_API_TOKEN dans le + # `.env` impose une valeur à la place. WATCHTOWER_HTTP_API_UPDATE: "true" - WATCHTOWER_HTTP_API_TOKEN: ${WATCHTOWER_HTTP_API_TOKEN:-} + WATCHTOWER_HTTP_API_TOKEN: ${WATCHTOWER_HTTP_API_TOKEN:-/config/updater-token} TZ: ${TZ:-Europe/Paris} # Joignable uniquement depuis le réseau Compose : jamais publié sur l'hôte. expose: @@ -159,6 +188,13 @@ services: # Accès nécessaire pour recréer les conteneurs. À n'activer que sur une # machine dont les accès sont maîtrisés. - /var/run/docker.sock:/var/run/docker.sock + # Lecture seule, pour le seul fichier `updater-token`. Le volume contient + # aussi les secrets de l'application : ce conteneur pouvait déjà les lire + # via le socket Docker, qui vaut un accès root sur l'hôte. + - app_config:/config:ro + depends_on: + updater-token: + condition: service_completed_successfully security_opt: - no-new-privileges:true diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index a73e738..f1562cc 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -8,6 +8,38 @@ UPLOADS_DIR="${UPLOADS_DIR:-/app/data/uploads}" umask 077 mkdir -p "$CONFIG_DIR" "$UPLOADS_DIR" +random_secret() { + node -e "process.stdout.write(require('node:crypto').randomBytes(48).toString('base64url'))" +} + +# Fichier temporaire propre à l'appel. Le PID ne suffirait pas : les conteneurs +# qui partagent ce volume démarrent tous leur entrypoint en PID 1 et +# écriraient donc dans le même fichier. +write_temp_file() { + temporary_path="$(mktemp "$1.tmp.XXXXXXXX")" + printf '%s\n' "$2" > "$temporary_path" + chmod 600 "$temporary_path" + printf '%s' "$temporary_path" +} + +# Écrit le secret uniquement s'il n'existe pas encore, puis renvoie la valeur +# finalement conservée. `ln` échoue quand la cible existe déjà : deux +# conteneurs lancés en même temps sur un volume vierge retiennent donc le même +# secret au lieu d'en générer chacun un. +create_secret_file() { + secret_path="$1" + proposed_value="$2" + temporary_path="$(write_temp_file "$secret_path" "$proposed_value")" + + if ln "$temporary_path" "$secret_path" 2>/dev/null; then + rm -f "$temporary_path" + printf '%s' "$proposed_value" + else + rm -f "$temporary_path" + sed -n '1p' "$secret_path" + fi +} + load_or_create_secret() { variable_name="$1" filename="$2" @@ -22,26 +54,19 @@ load_or_create_secret() { fi if [ -s "$secret_path" ]; then persisted_value="$(sed -n '1p' "$secret_path")" - if [ "$persisted_value" != "$current_value" ]; then - echo "Erreur : $variable_name diffère du secret conservé dans $secret_path." >&2 - echo "Conservez la valeur existante ou effectuez une rotation contrôlée avant de redémarrer." >&2 - exit 1 - fi else - temporary_path="$secret_path.tmp.$$" - printf '%s\n' "$current_value" > "$temporary_path" - chmod 600 "$temporary_path" - mv "$temporary_path" "$secret_path" + persisted_value="$(create_secret_file "$secret_path" "$current_value")" echo "Secret $variable_name fourni et conservé dans le volume de configuration." fi + if [ "$persisted_value" != "$current_value" ]; then + echo "Erreur : $variable_name diffère du secret conservé dans $secret_path." >&2 + echo "Conservez la valeur existante ou effectuez une rotation contrôlée avant de redémarrer." >&2 + exit 1 + fi elif [ -s "$secret_path" ]; then current_value="$(sed -n '1p' "$secret_path")" else - current_value="$(node -e "process.stdout.write(require('node:crypto').randomBytes(48).toString('base64url'))")" - temporary_path="$secret_path.tmp.$$" - printf '%s\n' "$current_value" > "$temporary_path" - chmod 600 "$temporary_path" - mv "$temporary_path" "$secret_path" + current_value="$(create_secret_file "$secret_path" "$(random_secret)")" echo "Secret $variable_name généré et conservé dans le volume de configuration." fi @@ -60,6 +85,24 @@ load_or_create_secret OAUTH_SECRET oauth-secret 32 load_or_create_secret SETTINGS_ENCRYPTION_KEY settings-encryption-key 32 load_or_create_secret CRON_SECRET cron-secret 32 +# Jeton autorisant l'application à demander un redémarrage immédiat au +# conteneur `updater`, sur la même machine. Le conteneur `updater` lit ce même +# fichier : personne n'a donc de valeur à choisir ni à recopier. Contrairement +# aux secrets ci-dessus, le remplacer n'invalide rien — une valeur imposée dans +# le `.env` prend simplement la place de celle qui a été générée. +updater_token_path="$CONFIG_DIR/updater-token" +if [ -n "${WATCHTOWER_HTTP_API_TOKEN:-}" ]; then + if [ "$(sed -n '1p' "$updater_token_path" 2>/dev/null || true)" != "$WATCHTOWER_HTTP_API_TOKEN" ]; then + mv "$(write_temp_file "$updater_token_path" "$WATCHTOWER_HTTP_API_TOKEN")" \ + "$updater_token_path" + fi +elif [ -s "$updater_token_path" ]; then + export WATCHTOWER_HTTP_API_TOKEN="$(sed -n '1p' "$updater_token_path")" +else + export WATCHTOWER_HTTP_API_TOKEN="$(create_secret_file "$updater_token_path" "$(random_secret)")" + echo "Jeton de déclenchement de l'updater généré et conservé dans le volume de configuration." +fi + if [ -z "${DATABASE_URL:-}" ] && [ "${SKIP_MIGRATIONS:-0}" != "1" ]; then export DATABASE_URL="$( node -e ' diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 7791b8a..92f2d45 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -252,21 +252,20 @@ WATCHTOWER_POLL_INTERVAL="3600" docker compose up -d ``` -Pour installer sans attendre le prochain contrôle, renseignez aussi un jeton -partagé entre l'application et l'`updater` : - -```env -WATCHTOWER_HTTP_API_TOKEN="…" # openssl rand -base64 32 -``` - -Un bouton **Appliquer maintenant** apparaît alors dans **Configuration → -Version et mises à jour** dès qu'une version plus récente est publiée. Sans ce -jeton, l'API de déclenchement de l'`updater` reste fermée et le bouton -n'apparaît pas : une mise à jour immédiate redémarre l'application, elle ne doit -pas pouvoir être lancée sans que vous l'ayez explicitement autorisée. Le port de +Un bouton **Appliquer maintenant** apparaît dans **Configuration → Version et +mises à jour** dès qu'une version plus récente est publiée, pour l'installer +sans attendre le prochain contrôle. Il n'y a rien à configurer : l'API de +déclenchement de l'`updater` demande un jeton, que l'entrypoint de +l'application génère au premier démarrage et conserve dans le volume +`app_config`. Le conteneur `updater-token`, lancé par le même profil, s'assure +qu'il existe avant que l'`updater` ne démarre et le relise. Le port de l'`updater` n'est jamais publié sur l'hôte, il n'est joignable que depuis le réseau Compose. +Renseigner `WATCHTOWER_HTTP_API_TOKEN` dans `.env` impose une valeur à la place +de celle qui est générée. C'est facultatif et sans effet sur la sécurité du +déclenchement. + Déroulé d'une mise à jour : l'`updater` détecte l'image, la télécharge, recrée les conteneurs, l'entrypoint applique les migrations, puis Caddy réachemine le trafic. Les requêtes reçues pendant la bascule patientent au lieu d'échouer @@ -276,6 +275,14 @@ Seuls `app` et `scheduler` portent le label `com.centurylinklabs.watchtower.enable` : PostgreSQL, Caddy et Mailpit ne sont jamais remplacés automatiquement. +L'`updater` ne met à jour que des images : `compose.yaml` vit sur le serveur et +reste tel quel. Après un `git pull` qui le modifie, `docker compose pull && +docker compose up -d` applique le nouveau fichier avec la dernière image. +`DOCKER_API_VERSION` en fait partie : sans elle, Watchtower s'adresse au démon +Docker dans une version d'API que les moteurs récents refusent, et aucune mise +à jour n'a lieu. `docker compose logs updater` le dit explicitement +(« client version 1.25 is too old »). + > L'`updater` a besoin d'accéder à `/var/run/docker.sock`, ce qui équivaut à un > accès root sur l'hôte. À réserver à une machine dont les accès sont > maîtrisés. Pour vous en passer, laissez `COMPOSE_PROFILES` vide et utilisez diff --git a/src/components/update-status-card.tsx b/src/components/update-status-card.tsx index f098c01..f0d6223 100644 --- a/src/components/update-status-card.tsx +++ b/src/components/update-status-card.tsx @@ -228,15 +228,15 @@ export function UpdateStatusCard({ status }: { status: UpdateStatus }) { {current.autoUpdate.enabled && !current.autoUpdate.canTriggerNow && (

- L’installation immédiate n’est pas activée. Elle demande un - secret partagé entre l’application et le service de mise à - jour, qui autorise le redémarrage demandé depuis cette page - — sans rapport avec l’accès à l’image, publique. Définissez - une valeur au hasard dans{" "} + L’installation immédiate n’est pas disponible : le jeton + qui autorise cette page à demander un redémarrage au + service de mise à jour n’a pas encore été généré. Il l’est + tout seul au démarrage ; relancez la pile sur le serveur + avec{" "} - WATCHTOWER_HTTP_API_TOKEN - {" "} - du .env du serveur, puis relancez la stack. + docker compose pull && docker compose up -d + + .

)} diff --git a/src/lib/services/updates.ts b/src/lib/services/updates.ts index 69bf955..fef0959 100644 --- a/src/lib/services/updates.ts +++ b/src/lib/services/updates.ts @@ -36,10 +36,10 @@ function watchtowerUrl() { } /** - * Jeton partagé entre l'application et l'`updater`. Sans lui, Watchtower - * n'écoute aucune demande de déclenchement et le bouton reste indisponible : - * une mise à jour immédiate redémarre l'application, elle ne doit pas pouvoir - * être déclenchée sans que l'exploitant l'ait explicitement autorisée. + * Jeton partagé entre l'application et l'`updater`, tous deux sur la même + * machine. L'entrypoint le génère au premier démarrage et le conserve dans le + * volume de configuration, où l'`updater` le relit : il n'y a aucune valeur à + * choisir. Il reste vide hors Docker, où aucun `updater` n'existe. */ function watchtowerToken() { return process.env.WATCHTOWER_HTTP_API_TOKEN?.trim() || ""; @@ -221,7 +221,7 @@ export async function triggerUpdateNow(): Promise { const token = watchtowerToken(); if (!token) { throw new Error( - "Le déclenchement immédiat n'est pas configuré : renseignez WATCHTOWER_HTTP_API_TOKEN.", + "Le déclenchement immédiat n'est pas disponible : relancez la pile Docker pour que le jeton partagé avec l'updater soit généré.", ); } @@ -234,7 +234,7 @@ export async function triggerUpdateNow(): Promise { }); if (response.status === 401) { throw new Error( - "Jeton refusé par l'updater : la valeur diffère entre les deux conteneurs.", + "Jeton refusé par l'updater : il a démarré avant que le jeton ne soit généré. Relancez la pile Docker.", ); } if (!response.ok) {