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) {