diff --git a/server/README.md b/server/README.md index 4e07008..faae5a6 100644 --- a/server/README.md +++ b/server/README.md @@ -47,5 +47,17 @@ Eksempel på body til `POST`: } ``` +Boardet bør identificere sig på alle kald. Aktivitetsdashboardet prioriterer +`X-Device-ID`, derefter `X-Client-Name` og til sidst klientens IP-adresse: + +```text +X-Device-ID: uno-r4-wifi-01 +X-Client-Name: wifi-status-sketch +``` + +Dashboardets egne forespørgsler markeres internt og gemmes ikke i +aktivitetsloggen. GET-kald uden `X-Device-ID` eller `X-Client-Name` udelades +også, så anonyme dashboard- og overvågningskald ikke overdøver boardtrafikken. + WiFi-oplysningerne ligger kun i den lokale `.env` til brug for den kommende board-sketch. De sendes ikke til browseren eller API-klienter. diff --git a/server/public/app.js b/server/public/app.js index 3a6c5d5..c4b159e 100644 --- a/server/public/app.js +++ b/server/public/app.js @@ -16,8 +16,11 @@ function escapeHtml(value) { async function refresh() { try { + const dashboardRequest = { headers: { "X-Monitor-Request": "dashboard" } }; const [healthResponse, payloadResponse, activityResponse] = await Promise.all([ - fetch("/api/health"), fetch("/api/payloads"), fetch("/api/activity"), + fetch("/api/health", dashboardRequest), + fetch("/api/payloads", dashboardRequest), + fetch("/api/activity", dashboardRequest), ]); if (![healthResponse, payloadResponse, activityResponse].every((response) => response.ok)) { throw new Error("Serveren returnerede en fejl"); @@ -31,12 +34,19 @@ async function refresh() { elements.apiCount.textContent = activity.filter((event) => event.source === "api").length; elements.serverCount.textContent = activity.filter((event) => event.source === "server").length; elements.payloads.textContent = JSON.stringify(payloads, null, 2); - elements.activity.innerHTML = activity.slice(0, 30).map((event) => ` + elements.activity.innerHTML = activity.slice(0, 30).map((event) => { + const caller = event.detail.caller; + const callerName = caller?.deviceId || caller?.clientName || caller?.ip || "ukendt"; + const detail = { ...event.detail }; + delete detail.caller; + return `
${escapeHtml(event.source)} ${new Date(event.timestamp).toLocaleTimeString("da-DK")} - ${escapeHtml(event.action)} ${escapeHtml(JSON.stringify(event.detail))} -
`).join("") || "

Ingen hændelser endnu.

"; + ${escapeHtml(callerName)} + ${escapeHtml(event.action)} ${escapeHtml(JSON.stringify(detail))} + `; + }).join("") || "

Ingen eksterne API-hændelser endnu.

"; } catch (error) { elements.health.textContent = "Server offline"; elements.health.className = "status"; diff --git a/server/public/styles.css b/server/public/styles.css index 1c0a8c0..eb51044 100644 --- a/server/public/styles.css +++ b/server/public/styles.css @@ -22,13 +22,14 @@ p { color: #91a99f; } .panel { padding: 24px; margin-top: 16px; } button { color: #e5f7ef; cursor: pointer; } .activity { margin-top: 18px; display: grid; gap: 8px; } -.event { display: grid; grid-template-columns: 85px 110px 1fr; gap: 12px; padding: 12px 0; border-top: 1px solid #183129; } +.event { display: grid; grid-template-columns: 75px 95px 150px 1fr; gap: 12px; padding: 12px 0; border-top: 1px solid #183129; } .source { color: #62e6ae; text-transform: uppercase; font-size: .72rem; font-weight: 800; } .time { color: #6f887e; font-variant-numeric: tabular-nums; } +.caller { color: #f5c96a; font-weight: 700; overflow-wrap: anywhere; } pre { overflow: auto; color: #bfead8; background: #07110e; padding: 18px; border-radius: 12px; } @media (max-width: 650px) { header, .panel-heading { align-items: flex-start; flex-direction: column; } .metrics { grid-template-columns: 1fr; } .event { grid-template-columns: 70px 1fr; } - .event .detail { grid-column: 1 / -1; } + .event .caller, .event .detail { grid-column: 1 / -1; } } diff --git a/server/src/server.js b/server/src/server.js index 7c905e4..8b3e6e4 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -21,6 +21,27 @@ function record(source, action, detail = {}) { activity.splice(200); } +function identifyCaller(req) { + const forwardedFor = req.get("x-forwarded-for")?.split(",")[0]?.trim(); + return { + deviceId: req.get("x-device-id") ?? null, + clientName: req.get("x-client-name") ?? null, + ip: forwardedFor ?? req.ip, + userAgent: req.get("user-agent") ?? null, + }; +} + +function isDashboardRequest(req) { + if (req.get("x-monitor-request") === "dashboard") return true; + const referer = req.get("referer"); + if (!referer) return false; + try { + return new URL(referer).host === req.get("host"); + } catch { + return false; + } +} + async function loadPayloads() { try { payloads = JSON.parse(await fs.readFile(dataFile, "utf8")); @@ -45,9 +66,14 @@ function requireBearer(req, res, next) { const expected = Buffer.from(bearerToken); const actual = Buffer.from(supplied); if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) { - record("api", "auth_failed", { method: req.method, path: req.path }); + record("api", "auth_failed", { + method: req.method, + path: req.path, + caller: identifyCaller(req), + }); return res.status(401).json({ error: "A valid bearer token is required" }); } + req.apiAuthenticated = true; next(); } @@ -55,9 +81,22 @@ const app = express(); app.disable("x-powered-by"); app.use(express.json({ limit: "64kb" })); app.use((req, res, next) => { - if (req.path.startsWith("/api/") && !["/api/activity", "/api/health"].includes(req.path)) { + const hasNamedCaller = Boolean(req.get("x-device-id") || req.get("x-client-name")); + const isRelevantRequest = req.method !== "GET" || hasNamedCaller; + if ( + req.path.startsWith("/api/") && + isRelevantRequest && + !isDashboardRequest(req) && + !["/api/activity", "/api/health"].includes(req.path) + ) { res.on("finish", () => { - record("api", "request", { method: req.method, path: req.path, status: res.statusCode }); + record("api", "request", { + method: req.method, + path: req.path, + status: res.statusCode, + authenticated: Boolean(req.apiAuthenticated), + caller: identifyCaller(req), + }); }); } next(); @@ -87,7 +126,7 @@ app.post("/api/payloads", requireBearer, async (req, res, next) => { }; payloads.push(payload); await persistPayloads(); - record("api", "created", { id: payload.id, topic: payload.topic }); + record("api", "created", { id: payload.id, topic: payload.topic, caller: identifyCaller(req) }); res.status(201).json(payload); } catch (error) { next(error); @@ -105,7 +144,11 @@ app.patch("/api/payloads/:id", requireBearer, async (req, res, next) => { updatedAt: new Date().toISOString(), }; await persistPayloads(); - record("api", "updated", { id: req.params.id, topic: payloads[index].topic }); + record("api", "updated", { + id: req.params.id, + topic: payloads[index].topic, + caller: identifyCaller(req), + }); res.json(payloads[index]); } catch (error) { next(error); @@ -118,7 +161,7 @@ app.delete("/api/payloads/:id", requireBearer, async (req, res, next) => { if (index === -1) return res.status(404).json({ error: "Payload not found" }); const [removed] = payloads.splice(index, 1); await persistPayloads(); - record("api", "deleted", { id: removed.id, topic: removed.topic }); + record("api", "deleted", { id: removed.id, topic: removed.topic, caller: identifyCaller(req) }); res.status(204).end(); } catch (error) { next(error);