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:
@@ -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