From eae3f89aca2cf7b9c000ddaa5fabad36e6fd35cb Mon Sep 17 00:00:00 2001 From: vl Date: Mon, 21 Sep 2026 22:17:16 +0200 Subject: [PATCH] fix: let the panel reach the API over plain HTTP, deliberately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd --- .env.example | 12 +++++-- DEPLOY.md | 66 ++++++++++++++++++++++++++++++------ app/api/display/route.ts | 6 ++++ app/api/log/route.ts | 6 ++++ app/api/setup/route.ts | 6 ++++ docker-compose.prod.yml | 27 +++++++++++++-- lib/device/transport.test.ts | 50 +++++++++++++++++++++++++++ lib/device/transport.ts | 35 +++++++++++++++++++ 8 files changed, 192 insertions(+), 16 deletions(-) create mode 100644 lib/device/transport.test.ts create mode 100644 lib/device/transport.ts diff --git a/.env.example b/.env.example index 0eab434..c52d55f 100644 --- a/.env.example +++ b/.env.example @@ -58,9 +58,15 @@ AUTHENTIK_ADMIN_GROUP=horaires-admins # Format d'image servi à l'appareil. Le firmware Seeed référence des .bmp ; # basculer sur `png` si l'appareil refuse le BMP. DEVICE_IMAGE_FORMAT=bmp -# Autorise l'appareil à appeler l'API en clair (HTTP). À n'activer que si le -# firmware ESP32 échoue sur la chaîne TLS. Le jeton d'appareil circulerait alors -# en clair : il est distinct de tout autre secret et révocable depuis /admin/parametres. +# Autorise l'appareil à appeler l'API en clair (HTTP). Les routes de l'écran +# REFUSENT une requête non chiffrée tant que ce réglage vaut autre chose que +# « 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 # 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. diff --git a/DEPLOY.md b/DEPLOY.md index 6b02bc3..3662c56 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -109,16 +109,60 @@ la sauvegarde préalable. ## Le panneau tombe sur le certificat TLS -Certains firmwares ESP32 échouent sur une chaîne de certificats que tous les -navigateurs acceptent. Si l'écran n'arrive à joindre le serveur qu'en clair, -`DEVICE_ALLOW_HTTP=true` existe — mais c'est le dernier recours, pas le premier. +**C'est arrivé sur cette installation, et c'est réglé — voici pourquoi, pour le +jour où ça recommence.** -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`. -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 - routes `/api/setup`, `/api/display`, `/api/log` et `/api/device/image/*`. - Le jeton d'appareil circulerait alors en clair : il est distinct de tout - autre secret et révocable depuis *Paramètres → Appareils*, ce qui rend - l'arbitrage tenable — mais l'administration, elle, reste en TLS. +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`. diff --git a/app/api/display/route.ts b/app/api/display/route.ts index 4ba2a64..ea3fe26 100644 --- a/app/api/display/route.ts +++ b/app/api/display/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { publicBaseUrl } from '@/lib/config'; import { clientIp, deviceHeader, deviceNumber } from '@/lib/device/headers'; +import { checkTransport } from '@/lib/device/transport'; import { computeRefreshRate } from '@/lib/device/refresh'; import { authenticateDevice } from '@/lib/device/session'; import { prisma } from '@/lib/db'; @@ -19,6 +20,11 @@ export const dynamic = 'force-dynamic'; * so the renderer must stay byte-stable for unchanged content. */ 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); if (!limit.allowed) { return NextResponse.json( diff --git a/app/api/log/route.ts b/app/api/log/route.ts index 9348379..262918e 100644 --- a/app/api/log/route.ts +++ b/app/api/log/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { authenticateDevice } from '@/lib/device/session'; import { clientIp } from '@/lib/device/headers'; +import { checkTransport } from '@/lib/device/transport'; import { prisma } from '@/lib/db'; import { rateLimit } from '@/lib/ratelimit'; @@ -22,6 +23,11 @@ type IncomingLog = { * start retrying, and these records are diagnostics, not data. */ 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); if (!limit.allowed) { return new NextResponse(null, { status: 429 }); diff --git a/app/api/setup/route.ts b/app/api/setup/route.ts index d5f25ea..e32ae57 100644 --- a/app/api/setup/route.ts +++ b/app/api/setup/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'; import { publicBaseUrl } from '@/lib/config'; import { generateDeviceToken, generateFriendlyId, hashToken, normaliseMac } from '@/lib/device/auth'; import { clientIp, deviceHeader } from '@/lib/device/headers'; +import { checkTransport } from '@/lib/device/transport'; import { prisma } from '@/lib/db'; import { rateLimit } from '@/lib/ratelimit'; 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. */ 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); if (!limit.allowed) { return NextResponse.json( diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ba983ff..bc64563 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -39,11 +39,34 @@ services: traefik.http.routers.horaires.middlewares: horaires-hsts traefik.http.services.horaires.loadbalancer.server.port: "3010" # 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.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: # 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. diff --git a/lib/device/transport.test.ts b/lib/device/transport.test.ts new file mode 100644 index 0000000..453f5be --- /dev/null +++ b/lib/device/transport.test.ts @@ -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, 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); + } + }); +}); diff --git a/lib/device/transport.ts b/lib/device/transport.ts new file mode 100644 index 0000000..53f6cb0 --- /dev/null +++ b/lib/device/transport.ts @@ -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' } }, + ), + }; +}