Files
ita-ito-horaires/DEPLOY.md
T
vliaudatandClaude Opus 5 eae3f89aca fix: let the panel reach the API over plain HTTP, deliberately
The e-ink firmware carries a certificate-authority bundle fixed when it
was built, so it cannot validate a chain rooted in an authority created
afterwards. Let's Encrypt's ISRG Root YR was issued in May 2026 and is
not even in an up-to-date Ubuntu CA bundle yet; the kit's firmware
predates it. The handshake fails before a request is ever sent, which is
why neither Traefik nor the application saw anything at all while the
device reported "API connection cannot be established".

Ruled out first, with evidence: TLS 1.2 and the ECDHE-RSA-AES-GCM suites
an ESP32 needs are both offered, and the intermediate is not
cross-signed by an older root, so no alternate path exists in what is
served.

A Traefik router now serves four device paths over :80, ahead of the
entrypoint-wide redirect. The administration stays on TLS. The device
token travels in clear; it is used for nothing else and is revocable
from the settings page, and the image URL is an unguessable content hash.

DEVICE_ALLOW_HTTP existed but was never read — a setting that does
nothing misrepresents what it protects. The device routes now refuse an
unencrypted request unless it is set, so opening this door is a written
decision rather than the silent consequence of a proxy change.

DEPLOY.md records the whole diagnosis, including the commands that
distinguish a TLS failure from an application one, and what to do the
day the firmware learns the new roots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-21 22:17:16 +02:00

169 lines
6.9 KiB
Markdown

# Déploiement
Procédures d'exploitation pour `horaires.ita-ito.com`. Le [README](README.md)
couvre l'installation locale et la configuration d'Authentik.
## Première installation sur le VPS
Prérequis : Docker, Docker Compose, et un Traefik déjà en route possédant le
réseau externe nommé par `TRAEFIK_NETWORK`.
```bash
git clone <dépôt> /opt/horaires && cd /opt/horaires
cp .env.example .env
sed -i "s|^AUTH_SECRET=.*|AUTH_SECRET=$(openssl rand -base64 33)|" .env
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -base64 24)|" .env
# Renseignez APP_DOMAIN, AUTH_URL, AUTH_AUTHENTIK_*, TRANSLATION_API_KEY.
mkdir -p backups
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build --wait
```
Les migrations s'appliquent au démarrage. Si elles échouent, le conteneur
s'arrête **avant** de servir quoi que ce soit, plutôt que d'exposer une base
incohérente : `docker compose logs app` dira pourquoi.
Ensuite :
```bash
docker compose exec app node .migrator/node_modules/prisma/build/index.js migrate status
curl -fsS https://horaires.ita-ito.com/api/health
```
Puis, une fois connecté : *Paramètres* pour le canton et les intervalles de
réveil, *Fériés* → **Synchroniser maintenant**, *Horaires* pour la vraie
semaine, et enfin l'appairage de l'écran (README).
## Mise à jour
```bash
cd /opt/horaires
./scripts/backup.sh # ou attendre le dump nocturne
git pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build --wait
docker compose logs -f app # ^C une fois « démarrage de l'application »
```
`--wait` rend la main seulement quand le *healthcheck* passe. S'il ne passe pas,
la mise à jour a échoué et l'ancien conteneur a déjà été remplacé : voir le
retour arrière ci-dessous.
**Avant une mise à jour qui touche au rendu** (polices, gabarit, logo),
sachez que le nom de fichier de l'image change et que tous les écrans
redessineront une fois. C'est sans gravité, seulement un cycle de batterie.
## Sauvegarde
Le service `backup` dépose un dump compressé par jour dans `./backups` et purge
au-delà de `BACKUP_KEEP_DAYS` (14 par défaut).
Dump immédiat :
```bash
docker compose exec -T db pg_dump -U horaires horaires | gzip > backups/horaires-$(date +%Y%m%d-%H%M%S).sql.gz
```
Ces fichiers contiennent les horaires, les messages et le journal d'audit. Ils
ne contiennent **aucun secret** : les jetons d'appareil n'y figurent que sous
forme d'empreintes, et les identifiants Authentik vivent dans `.env`, qui n'est
pas dans les sauvegardes. Sauvegardez `.env` séparément, ailleurs.
## Restauration
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml stop app
gunzip -c backups/horaires-20260920-030000.sql.gz \
| docker compose exec -T db psql -U horaires -d horaires
docker compose -f docker-compose.yml -f docker-compose.prod.yml start app
```
Après une restauration, les écrans appairés depuis le dump ne le sont plus du
point de vue de la base : ils recevront un 401 et devront être réappairés par le
portail captif. Rien d'autre ne se perd.
## Retour arrière
L'image porte la version du dépôt, donc revenir en arrière veut dire revenir au
commit :
```bash
git log --oneline -10
git checkout <commit-précédent>
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build --wait
```
**Si la mise à jour comportait une migration**, revenir au code ne suffit pas :
le schéma est déjà migré. Prisma ne défait pas une migration. Restaurez alors le
dump pris avant la mise à jour, puis reprenez le code. C'est la raison d'être de
la sauvegarde préalable.
## Ce qu'il faut surveiller
| Signe | Où | Que faire |
|---|---|---|
| L'écran n'a pas donné signe de vie | alerte du tableau de bord | batterie, puis Wi-Fi de la boutique |
| Synchro des fériés en échec | alerte, page Fériés | le cache local tient ; relancer à la main |
| Messages sans traduction | alerte, page Messages | `npm run translate:test` pour savoir pourquoi |
| Conteneur *unhealthy* | `docker compose ps` | `docker compose logs app` |
## Le panneau tombe sur le certificat TLS
**C'est arrivé sur cette installation, et c'est réglé — voici pourquoi, pour le
jour où ça recommence.**
Le firmware de l'écran embarque un magasin d'autorités de certification **figé
au moment de sa compilation**. Il ne peut donc pas valider une chaîne qui
s'enracine sur une autorité créée après lui. Let's Encrypt a mis en service la
racine `ISRG Root YR` le 13 mai 2026 ; le firmware du kit est antérieur, et la
poignée de main TLS échoue **avant** qu'une seule requête soit émise — d'où le
symptôme déroutant : l'écran affiche « API connection cannot be established »
et ni Traefik ni l'application n'ont rien vu passer.
Diagnostic, dans l'ordre :
1. **L'écran a-t-il seulement atteint le serveur ?**
`docker compose logs app --since 30m | grep /api/setup` et
`docker logs traefik --since 30m | grep horaires`.
Si les deux sont vides, l'échec est dans la couche TLS, pas dans l'application.
2. **Quelle chaîne est servie, et jusqu'à quelle racine ?**
```bash
echo | openssl s_client -connect horaires.ita-ito.com:443 \
-servername horaires.ita-ito.com 2>/dev/null | grep -E "^ *[0-9] s:"
```
Comparez la racine à ce que le système connaît :
```bash
openssl crl2pkcs7 -nocrl -certfile /etc/ssl/certs/ca-certificates.crt \
| openssl pkcs7 -print_certs -noout | grep "Root YR"
```
Si une machine à jour ne la connaît pas, un firmware de 2025 encore moins.
3. **Le protocole est-il en cause ?** Souvent soupçonné, rarement coupable :
```bash
echo | openssl s_client -connect horaires.ita-ito.com:443 -tls1_2 | grep "Cipher is"
```
Un ESP32 a besoin de TLS 1.2 et d'une suite `ECDHE-RSA-AES*-GCM`.
Deux corrections possibles :
- **Préférer une racine ancienne.** `preferredChain: "ISRG Root X1"` sur le
resolver dans `traefik.yml`, puis renouvellement. `ISRG Root X1` est dans à
peu près tous les magasins depuis 2021. Aucun compromis de sécurité, mais
cela dépend de ce que Let's Encrypt propose encore comme chaîne alternative,
et cela touche le Traefik partagé par tous les sites.
- **Servir l'écran en clair** — ce qui est fait ici. Le routeur
`horaires-device` dans `docker-compose.prod.yml` expose sur `:80` les seules
routes `/api/setup`, `/api/display`, `/api/log` et `/api/device/`, avec une
priorité qui passe devant la redirection HTTP→HTTPS générale. L'administration
reste en TLS. Le jeton d'appareil circule alors en clair : il ne sert à rien
d'autre et se révoque depuis *Paramètres → Appareils*.
L'application **refuse** une requête d'appareil non chiffrée tant que
`DEVICE_ALLOW_HTTP=true` n'est pas posé : ouvrir cette porte est une décision
écrite, pas la conséquence silencieuse d'une configuration de proxy.
Le jour où le firmware apprend les racines récentes, retirez le routeur
`horaires-device` et remettez `DEVICE_ALLOW_HTTP=false`.