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
169 lines
6.9 KiB
Markdown
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`.
|