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:
+9
-3
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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(
|
||||
|
||||
+25
-2
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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' } },
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user