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
This commit is contained in:
2026-09-21 22:17:16 +02:00
co-authored by Claude Opus 5
parent efd91c3e8e
commit eae3f89aca
8 changed files with 192 additions and 16 deletions
+9 -3
View File
@@ -58,9 +58,15 @@ AUTHENTIK_ADMIN_GROUP=horaires-admins
# Format d'image servi à l'appareil. Le firmware Seeed référence des .bmp ; # Format d'image servi à l'appareil. Le firmware Seeed référence des .bmp ;
# basculer sur `png` si l'appareil refuse le BMP. # basculer sur `png` si l'appareil refuse le BMP.
DEVICE_IMAGE_FORMAT=bmp DEVICE_IMAGE_FORMAT=bmp
# Autorise l'appareil à appeler l'API en clair (HTTP). À n'activer que si le # Autorise l'appareil à appeler l'API en clair (HTTP). Les routes de l'écran
# firmware ESP32 échoue sur la chaîne TLS. Le jeton d'appareil circulerait alors # REFUSENT une requête non chiffrée tant que ce réglage vaut autre chose que
# en clair : il est distinct de tout autre secret et révocable depuis /admin/parametres. # « true » : c'est une décision explicite, pas un effet de bord d'une
# configuration de proxy.
#
# À activer quand le firmware ESP32 ne peut pas valider la chaîne TLS — le cas
# lorsque la racine Let's Encrypt est plus récente que le firmware lui-même.
# Le jeton d'appareil circule alors en clair : il ne sert à rien d'autre et se
# révoque depuis /admin/parametres. Voir DEPLOY.md.
DEVICE_ALLOW_HTTP=false DEVICE_ALLOW_HTTP=false
# Intervalles de réveil, en secondes. Court quand la boutique est ouverte ou sur le # Intervalles de réveil, en secondes. Court quand la boutique est ouverte ou sur le
# point de changer d'état, long la nuit et les jours de fermeture. # point de changer d'état, long la nuit et les jours de fermeture.
+55 -11
View File
@@ -109,16 +109,60 @@ la sauvegarde préalable.
## Le panneau tombe sur le certificat TLS ## Le panneau tombe sur le certificat TLS
Certains firmwares ESP32 échouent sur une chaîne de certificats que tous les **C'est arrivé sur cette installation, et c'est réglé — voici pourquoi, pour le
navigateurs acceptent. Si l'écran n'arrive à joindre le serveur qu'en clair, jour où ça recommence.**
`DEVICE_ALLOW_HTTP=true` existe — mais c'est le dernier recours, pas le premier.
Dans l'ordre : 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.
1. Vérifiez que l'écran atteint bien le domaine : `docker compose logs app | grep /api/setup`. Diagnostic, dans l'ordre :
2. Vérifiez la chaîne servie : `openssl s_client -connect horaires.ita-ito.com:443 -servername horaires.ita-ito.com | head -20`. Une chaîne incomplète est le cas le plus fréquent et se corrige côté Traefik, pas côté écran.
3. En dernier recours seulement, exposez un hôte virtuel en clair réservé aux 1. **L'écran a-t-il seulement atteint le serveur ?**
routes `/api/setup`, `/api/display`, `/api/log` et `/api/device/image/*`. `docker compose logs app --since 30m | grep /api/setup` et
Le jeton d'appareil circulerait alors en clair : il est distinct de tout `docker logs traefik --since 30m | grep horaires`.
autre secret et révocable depuis *Paramètres → Appareils*, ce qui rend Si les deux sont vides, l'échec est dans la couche TLS, pas dans l'application.
l'arbitrage tenable — mais l'administration, elle, reste en TLS.
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`.
+6
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { publicBaseUrl } from '@/lib/config'; import { publicBaseUrl } from '@/lib/config';
import { clientIp, deviceHeader, deviceNumber } from '@/lib/device/headers'; import { clientIp, deviceHeader, deviceNumber } from '@/lib/device/headers';
import { checkTransport } from '@/lib/device/transport';
import { computeRefreshRate } from '@/lib/device/refresh'; import { computeRefreshRate } from '@/lib/device/refresh';
import { authenticateDevice } from '@/lib/device/session'; import { authenticateDevice } from '@/lib/device/session';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
@@ -19,6 +20,11 @@ export const dynamic = 'force-dynamic';
* so the renderer must stay byte-stable for unchanged content. * so the renderer must stay byte-stable for unchanged content.
*/ */
export async function GET(request: Request) { export async function GET(request: Request) {
const transport = checkTransport(request);
if (!transport.ok) {
return transport.response;
}
const limit = rateLimit(`display:${clientIp(request)}`, 60, 60_000); const limit = rateLimit(`display:${clientIp(request)}`, 60, 60_000);
if (!limit.allowed) { if (!limit.allowed) {
return NextResponse.json( return NextResponse.json(
+6
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { authenticateDevice } from '@/lib/device/session'; import { authenticateDevice } from '@/lib/device/session';
import { clientIp } from '@/lib/device/headers'; import { clientIp } from '@/lib/device/headers';
import { checkTransport } from '@/lib/device/transport';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { rateLimit } from '@/lib/ratelimit'; import { rateLimit } from '@/lib/ratelimit';
@@ -22,6 +23,11 @@ type IncomingLog = {
* start retrying, and these records are diagnostics, not data. * start retrying, and these records are diagnostics, not data.
*/ */
export async function POST(request: Request) { export async function POST(request: Request) {
const transport = checkTransport(request);
if (!transport.ok) {
return transport.response;
}
const limit = rateLimit(`log:${clientIp(request)}`, 30, 60_000); const limit = rateLimit(`log:${clientIp(request)}`, 30, 60_000);
if (!limit.allowed) { if (!limit.allowed) {
return new NextResponse(null, { status: 429 }); return new NextResponse(null, { status: 429 });
+6
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server';
import { publicBaseUrl } from '@/lib/config'; import { publicBaseUrl } from '@/lib/config';
import { generateDeviceToken, generateFriendlyId, hashToken, normaliseMac } from '@/lib/device/auth'; import { generateDeviceToken, generateFriendlyId, hashToken, normaliseMac } from '@/lib/device/auth';
import { clientIp, deviceHeader } from '@/lib/device/headers'; import { clientIp, deviceHeader } from '@/lib/device/headers';
import { checkTransport } from '@/lib/device/transport';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { rateLimit } from '@/lib/ratelimit'; import { rateLimit } from '@/lib/ratelimit';
import { buildCurrentScreen, renderAndStore } from '@/lib/screen/service'; import { buildCurrentScreen, renderAndStore } from '@/lib/screen/service';
@@ -19,6 +20,11 @@ export const dynamic = 'force-dynamic';
* page — which is the correct outcome, not a gap. * page — which is the correct outcome, not a gap.
*/ */
export async function GET(request: Request) { export async function GET(request: Request) {
const transport = checkTransport(request);
if (!transport.ok) {
return transport.response;
}
const limit = rateLimit(`setup:${clientIp(request)}`, 10, 60_000); const limit = rateLimit(`setup:${clientIp(request)}`, 10, 60_000);
if (!limit.allowed) { if (!limit.allowed) {
return NextResponse.json( return NextResponse.json(
+25 -2
View File
@@ -39,11 +39,34 @@ services:
traefik.http.routers.horaires.middlewares: horaires-hsts traefik.http.routers.horaires.middlewares: horaires-hsts
traefik.http.services.horaires.loadbalancer.server.port: "3010" traefik.http.services.horaires.loadbalancer.server.port: "3010"
# The admin is only ever served over TLS; say so to the browsers. # The admin is only ever served over TLS; say so to the browsers.
# Note the panel is NOT a browser: if its firmware trips over the
# certificate chain, see DEPLOY.md before reaching for DEVICE_ALLOW_HTTP.
traefik.http.middlewares.horaires-hsts.headers.stsSeconds: "31536000" traefik.http.middlewares.horaires-hsts.headers.stsSeconds: "31536000"
traefik.http.middlewares.horaires-hsts.headers.stsIncludeSubdomains: "true" traefik.http.middlewares.horaires-hsts.headers.stsIncludeSubdomains: "true"
# --- The panel, in clear, on four paths only ---
#
# 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 — which is exactly the case with Let's Encrypt's ISRG Root YR
# (May 2026). The handshake fails before a request is ever sent, which is
# why neither Traefik nor the application sees anything at all.
#
# This router therefore serves the four device paths over plain HTTP. The
# administration stays on TLS. The trade-off is real and bounded: the
# device token travels in clear, it is used for nothing else, and it can
# be revoked from Paramètres → Appareils. The image URL is an unguessable
# content hash.
#
# The priority beats the entrypoint-wide HTTP→HTTPS redirection, which is
# otherwise applied to everything on :80. Remove this block the day the
# firmware learns the new roots, and set DEVICE_ALLOW_HTTP=false — the
# application refuses plain requests without it.
traefik.http.routers.horaires-device.rule: >-
Host(`${APP_DOMAIN}`) && (PathPrefix(`/api/setup`) || PathPrefix(`/api/display`)
|| PathPrefix(`/api/log`) || PathPrefix(`/api/device/`))
traefik.http.routers.horaires-device.entrypoints: web
traefik.http.routers.horaires-device.priority: "2147483647"
traefik.http.routers.horaires-device.service: horaires
backup: backup:
# A nightly dump kept for two weeks. Small, boring, and the only thing # A nightly dump kept for two weeks. Small, boring, and the only thing
# standing between a bad migration and retyping a year of opening hours. # standing between a bad migration and retyping a year of opening hours.
+50
View File
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it } from 'vitest';
import { checkTransport } from './transport';
const original = process.env.DEVICE_ALLOW_HTTP;
afterEach(() => {
process.env.DEVICE_ALLOW_HTTP = original;
});
function request(headers: Record<string, string>, url = 'https://horaires.test/api/display') {
return new Request(url, { headers });
}
describe('checkTransport', () => {
it('accepts a request the proxy says came over TLS', () => {
process.env.DEVICE_ALLOW_HTTP = 'false';
expect(checkTransport(request({ 'x-forwarded-proto': 'https' })).ok).toBe(true);
});
it('refuses a plain request when plain is not allowed', () => {
process.env.DEVICE_ALLOW_HTTP = 'false';
const result = checkTransport(request({ 'x-forwarded-proto': 'http' }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.response.status).toBe(403);
}
});
it('accepts a plain request once it has been allowed deliberately', () => {
// The panel's certificate bundle is fixed at build time; when it cannot
// trust the chain, this is the recorded decision to let it through.
process.env.DEVICE_ALLOW_HTTP = 'true';
expect(checkTransport(request({ 'x-forwarded-proto': 'http' })).ok).toBe(true);
});
it('falls back to the request URL when no proxy header is present', () => {
process.env.DEVICE_ALLOW_HTTP = 'false';
expect(checkTransport(request({})).ok).toBe(true);
expect(checkTransport(request({}, 'http://horaires.test/api/display')).ok).toBe(false);
});
it('treats any value other than "true" as a refusal', () => {
// A half-set variable must not quietly open the door.
for (const value of ['', 'yes', '1', 'TRUE', 'oui']) {
process.env.DEVICE_ALLOW_HTTP = value;
expect(checkTransport(request({ 'x-forwarded-proto': 'http' })).ok).toBe(false);
}
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* Whether a device request arrived over a transport we accept.
*
* The panel's firmware carries a fixed certificate-authority bundle, so it
* cannot validate a chain rooted in an authority created after the firmware
* was built. When that happens the only way to reach the device is plain HTTP,
* and `DEVICE_ALLOW_HTTP` is the deliberate, auditable decision to allow it.
*
* Without that decision recorded, a proxy misconfiguration could silently
* start serving the device token in clear. This turns the setting from a
* comment into a rule.
*/
import { deviceAllowsHttp } from '@/lib/config';
export type TransportCheck = { ok: true } | { ok: false; response: Response };
export function checkTransport(request: Request): TransportCheck {
// Behind a reverse proxy the socket is always plain; the forwarded header is
// what says how the client actually connected.
const forwarded = request.headers.get('x-forwarded-proto');
const protocol = forwarded ?? new URL(request.url).protocol.replace(':', '');
if (protocol === 'https' || deviceAllowsHttp()) {
return { ok: true };
}
return {
ok: false,
response: Response.json(
{ error: 'HTTPS requis pour cet appareil.' },
{ status: 403, headers: { 'Cache-Control': 'no-store' } },
),
};
}