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
71 lines
2.1 KiB
TypeScript
71 lines
2.1 KiB
TypeScript
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';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
/** Never let a firmware in a retry loop fill the table in one request. */
|
|
const MAX_ENTRIES_PER_CALL = 50;
|
|
|
|
type IncomingLog = {
|
|
message?: unknown;
|
|
level?: unknown;
|
|
created_at?: unknown;
|
|
};
|
|
|
|
/**
|
|
* Firmware-side logs. Answered with 204 whatever happens to the contents: a
|
|
* device that cannot file a log must not conclude the server is down and
|
|
* 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 });
|
|
}
|
|
|
|
const device = await authenticateDevice(request);
|
|
if (!device) {
|
|
return NextResponse.json({ error: 'Jeton invalide' }, { status: 401 });
|
|
}
|
|
|
|
let entries: IncomingLog[] = [];
|
|
try {
|
|
const body: unknown = await request.json();
|
|
const logs = (body as { logs?: unknown } | null)?.logs;
|
|
if (Array.isArray(logs)) {
|
|
entries = logs.slice(0, MAX_ENTRIES_PER_CALL) as IncomingLog[];
|
|
}
|
|
} catch {
|
|
// A malformed body is a diagnostic in itself; 204 keeps the device calm.
|
|
return new NextResponse(null, { status: 204 });
|
|
}
|
|
|
|
if (entries.length > 0) {
|
|
await prisma.deviceLog.createMany({
|
|
data: entries.map((entry) => ({
|
|
deviceId: device.id,
|
|
level: levelOf(entry.level),
|
|
message: String(entry.message ?? '').slice(0, 2000) || '(vide)',
|
|
payload: entry as object,
|
|
})),
|
|
});
|
|
}
|
|
|
|
return new NextResponse(null, { status: 204 });
|
|
}
|
|
|
|
function levelOf(value: unknown): 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' {
|
|
const level = String(value ?? '').toUpperCase();
|
|
return level === 'DEBUG' || level === 'WARN' || level === 'ERROR' ? level : 'INFO';
|
|
}
|