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
104 lines
3.4 KiB
TypeScript
104 lines
3.4 KiB
TypeScript
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';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
/**
|
|
* First contact. The firmware sends its MAC in the `ID` header and expects a
|
|
* token back, which it then stores and presents on every later call.
|
|
*
|
|
* A device that is already registered is answered with an empty `api_key`: we
|
|
* only ever stored the digest, so the original cannot be handed out again. If
|
|
* a panel ever loses its token, an administrator re-pairs it from the settings
|
|
* 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(
|
|
{ status: 429, message: 'Trop de tentatives' },
|
|
{ status: 429, headers: { 'Retry-After': String(limit.retryAfter) } },
|
|
);
|
|
}
|
|
|
|
const mac = normaliseMac(deviceHeader(request, 'id'));
|
|
if (!mac) {
|
|
return NextResponse.json(
|
|
{ status: 404, message: 'Adresse MAC absente ou invalide' },
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
|
|
const baseUrl = publicBaseUrl(request);
|
|
const existing = await prisma.device.findUnique({ where: { macAddress: mac } });
|
|
|
|
if (existing) {
|
|
return NextResponse.json({
|
|
status: 200,
|
|
api_key: '',
|
|
friendly_id: existing.friendlyId,
|
|
image_url: await welcomeImageUrl(baseUrl),
|
|
message: 'Appareil déjà appairé',
|
|
});
|
|
}
|
|
|
|
const token = generateDeviceToken();
|
|
const device = await prisma.device.create({
|
|
data: {
|
|
macAddress: mac,
|
|
friendlyId: await uniqueFriendlyId(),
|
|
apiKeyHash: hashToken(token),
|
|
},
|
|
});
|
|
|
|
return NextResponse.json({
|
|
status: 200,
|
|
api_key: token,
|
|
friendly_id: device.friendlyId,
|
|
image_url: await welcomeImageUrl(baseUrl),
|
|
message: 'Bienvenue',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Never let a rendering failure cost us the pairing.
|
|
*
|
|
* The token is issued once and only its digest is kept, so if the response
|
|
* that carries it fails the device is stranded: registered, but holding no
|
|
* credential, and unable to register again. The welcome image is worth far
|
|
* less than that, and the next /api/display call will produce one anyway.
|
|
*/
|
|
async function welcomeImageUrl(baseUrl: string): Promise<string> {
|
|
try {
|
|
const { payload, settings } = await buildCurrentScreen(new Date(), baseUrl);
|
|
const image = await renderAndStore(payload, settings.imageFormat);
|
|
return `${baseUrl}/api/device/image/${image.filename}`;
|
|
} catch (error) {
|
|
console.error('Could not render the welcome image', error);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
async function uniqueFriendlyId(): Promise<string> {
|
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
const candidate = generateFriendlyId();
|
|
const taken = await prisma.device.findUnique({ where: { friendlyId: candidate } });
|
|
if (!taken) {
|
|
return candidate;
|
|
}
|
|
}
|
|
throw new Error('Could not allocate a friendly id');
|
|
}
|