Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
42 changes: 39 additions & 3 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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

Expand Down
71 changes: 57 additions & 14 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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

Expand All @@ -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 '
Expand Down
31 changes: 19 additions & 12 deletions docs/DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 8 additions & 8 deletions src/components/update-status-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,15 +228,15 @@ export function UpdateStatusCard({ status }: { status: UpdateStatus }) {
{current.autoUpdate.enabled &&
!current.autoUpdate.canTriggerNow && (
<p className="mt-2 text-xs font-medium leading-5 text-slate-500">
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{" "}
<code className="rounded bg-slate-100 px-1 py-0.5 font-mono text-[11px] text-slate-700">
WATCHTOWER_HTTP_API_TOKEN
</code>{" "}
du .env du serveur, puis relancez la stack.
docker compose pull &amp;&amp; docker compose up -d
</code>
.
</p>
)}
</div>
Expand Down
12 changes: 6 additions & 6 deletions src/lib/services/updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() || "";
Expand Down Expand Up @@ -221,7 +221,7 @@ export async function triggerUpdateNow(): Promise<TriggerOutcome> {
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é.",
);
}

Expand All @@ -234,7 +234,7 @@ export async function triggerUpdateNow(): Promise<TriggerOutcome> {
});
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) {
Expand Down
Loading