Identify API callers and filter dashboard traffic

This commit is contained in:
Christian Johannesen 2026-10-24 20:13:31 +02:00
parent 1ed4e759b9
commit dc45e75219
4 changed files with 78 additions and 12 deletions

View file

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

View file

@ -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 `
<div class="event">
<span class="source">${escapeHtml(event.source)}</span>
<span class="time">${new Date(event.timestamp).toLocaleTimeString("da-DK")}</span>
<span class="detail"><strong>${escapeHtml(event.action)}</strong> ${escapeHtml(JSON.stringify(event.detail))}</span>
</div>`).join("") || "<p>Ingen hændelser endnu.</p>";
<span class="caller">${escapeHtml(callerName)}</span>
<span class="detail"><strong>${escapeHtml(event.action)}</strong> ${escapeHtml(JSON.stringify(detail))}</span>
</div>`;
}).join("") || "<p>Ingen eksterne API-hændelser endnu.</p>";
} catch (error) {
elements.health.textContent = "Server offline";
elements.health.className = "status";

View file

@ -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; }
}

View file

@ -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);