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
|
WiFi-oplysningerne ligger kun i den lokale `.env` til brug for den kommende
|
||||||
board-sketch. De sendes ikke til browseren eller API-klienter.
|
board-sketch. De sendes ikke til browseren eller API-klienter.
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,11 @@ function escapeHtml(value) {
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
|
const dashboardRequest = { headers: { "X-Monitor-Request": "dashboard" } };
|
||||||
const [healthResponse, payloadResponse, activityResponse] = await Promise.all([
|
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)) {
|
if (![healthResponse, payloadResponse, activityResponse].every((response) => response.ok)) {
|
||||||
throw new Error("Serveren returnerede en fejl");
|
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.apiCount.textContent = activity.filter((event) => event.source === "api").length;
|
||||||
elements.serverCount.textContent = activity.filter((event) => event.source === "server").length;
|
elements.serverCount.textContent = activity.filter((event) => event.source === "server").length;
|
||||||
elements.payloads.textContent = JSON.stringify(payloads, null, 2);
|
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">
|
<div class="event">
|
||||||
<span class="source">${escapeHtml(event.source)}</span>
|
<span class="source">${escapeHtml(event.source)}</span>
|
||||||
<span class="time">${new Date(event.timestamp).toLocaleTimeString("da-DK")}</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>
|
<span class="caller">${escapeHtml(callerName)}</span>
|
||||||
</div>`).join("") || "<p>Ingen hændelser endnu.</p>";
|
<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) {
|
} catch (error) {
|
||||||
elements.health.textContent = "Server offline";
|
elements.health.textContent = "Server offline";
|
||||||
elements.health.className = "status";
|
elements.health.className = "status";
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,14 @@ p { color: #91a99f; }
|
||||||
.panel { padding: 24px; margin-top: 16px; }
|
.panel { padding: 24px; margin-top: 16px; }
|
||||||
button { color: #e5f7ef; cursor: pointer; }
|
button { color: #e5f7ef; cursor: pointer; }
|
||||||
.activity { margin-top: 18px; display: grid; gap: 8px; }
|
.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; }
|
.source { color: #62e6ae; text-transform: uppercase; font-size: .72rem; font-weight: 800; }
|
||||||
.time { color: #6f887e; font-variant-numeric: tabular-nums; }
|
.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; }
|
pre { overflow: auto; color: #bfead8; background: #07110e; padding: 18px; border-radius: 12px; }
|
||||||
@media (max-width: 650px) {
|
@media (max-width: 650px) {
|
||||||
header, .panel-heading { align-items: flex-start; flex-direction: column; }
|
header, .panel-heading { align-items: flex-start; flex-direction: column; }
|
||||||
.metrics { grid-template-columns: 1fr; }
|
.metrics { grid-template-columns: 1fr; }
|
||||||
.event { grid-template-columns: 70px 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);
|
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() {
|
async function loadPayloads() {
|
||||||
try {
|
try {
|
||||||
payloads = JSON.parse(await fs.readFile(dataFile, "utf8"));
|
payloads = JSON.parse(await fs.readFile(dataFile, "utf8"));
|
||||||
|
|
@ -45,9 +66,14 @@ function requireBearer(req, res, next) {
|
||||||
const expected = Buffer.from(bearerToken);
|
const expected = Buffer.from(bearerToken);
|
||||||
const actual = Buffer.from(supplied);
|
const actual = Buffer.from(supplied);
|
||||||
if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) {
|
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" });
|
return res.status(401).json({ error: "A valid bearer token is required" });
|
||||||
}
|
}
|
||||||
|
req.apiAuthenticated = true;
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,9 +81,22 @@ const app = express();
|
||||||
app.disable("x-powered-by");
|
app.disable("x-powered-by");
|
||||||
app.use(express.json({ limit: "64kb" }));
|
app.use(express.json({ limit: "64kb" }));
|
||||||
app.use((req, res, next) => {
|
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", () => {
|
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();
|
next();
|
||||||
|
|
@ -87,7 +126,7 @@ app.post("/api/payloads", requireBearer, async (req, res, next) => {
|
||||||
};
|
};
|
||||||
payloads.push(payload);
|
payloads.push(payload);
|
||||||
await persistPayloads();
|
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);
|
res.status(201).json(payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|
@ -105,7 +144,11 @@ app.patch("/api/payloads/:id", requireBearer, async (req, res, next) => {
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
await persistPayloads();
|
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]);
|
res.json(payloads[index]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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" });
|
if (index === -1) return res.status(404).json({ error: "Payload not found" });
|
||||||
const [removed] = payloads.splice(index, 1);
|
const [removed] = payloads.splice(index, 1);
|
||||||
await persistPayloads();
|
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();
|
res.status(204).end();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue