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
6.9 KiB
Déploiement
Procédures d'exploitation pour horaires.ita-ito.com. Le README
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.
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 :
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
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 :
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
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 :
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 :
-
L'écran a-t-il seulement atteint le serveur ?
docker compose logs app --since 30m | grep /api/setupetdocker logs traefik --since 30m | grep horaires. Si les deux sont vides, l'échec est dans la couche TLS, pas dans l'application. -
Quelle chaîne est servie, et jusqu'à quelle racine ?
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 :
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.
-
Le protocole est-il en cause ? Souvent soupçonné, rarement coupable :
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 danstraefik.yml, puis renouvellement.ISRG Root X1est 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-devicedansdocker-compose.prod.ymlexpose sur:80les seules routes/api/setup,/api/display,/api/loget/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=truen'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.