Identify API callers and filter dashboard traffic
This commit is contained in:
parent
1ed4e759b9
commit
dc45e75219
4 changed files with 78 additions and 12 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue