mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
feat(diagnostics): harden ingestion and delivery
This commit is contained in:
267
services/diagnostics-api/src/index.ts
Normal file
267
services/diagnostics-api/src/index.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
normalizeBug,
|
||||
normalizeCrash,
|
||||
normalizeEvents,
|
||||
readJsonObject,
|
||||
} from "./input";
|
||||
import {
|
||||
type DiagnosticsEnv,
|
||||
runRetention,
|
||||
storeBug,
|
||||
storeCrash,
|
||||
storeEvents,
|
||||
} from "./storage";
|
||||
|
||||
const DEFAULT_MAX_BODY_BYTES = 262_144;
|
||||
const HARD_MAX_BODY_BYTES = 1_048_576;
|
||||
const DEFAULT_MAX_EVENTS = 50;
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: DiagnosticsEnv, _ctx: ExecutionContext): Promise<Response> {
|
||||
const requestId = crypto.randomUUID();
|
||||
const url = new URL(request.url);
|
||||
try {
|
||||
if (request.method === "GET" && url.pathname === "/live") {
|
||||
return json({ ok: true, service: "vnidrop-diagnostics", schema: 1 }, 200, requestId);
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/health") {
|
||||
if (await sourceRateLimited(request, url.pathname, env)) {
|
||||
return json({ error: "rate_limited" }, 429, requestId, { "retry-after": "60" });
|
||||
}
|
||||
const authError = await authorize(request, env);
|
||||
if (authError) return json({ error: authError.error }, authError.status, requestId);
|
||||
return await readiness(env, requestId);
|
||||
}
|
||||
|
||||
if (!isIngestPath(url.pathname)) {
|
||||
return json({ error: "not_found" }, 404, requestId);
|
||||
}
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: responseHeaders(requestId, { allow: "POST, OPTIONS" }),
|
||||
});
|
||||
}
|
||||
if (request.method !== "POST") {
|
||||
return json(
|
||||
{ error: "method_not_allowed" },
|
||||
405,
|
||||
requestId,
|
||||
{ allow: "POST, OPTIONS" },
|
||||
);
|
||||
}
|
||||
|
||||
if (await sourceRateLimited(request, url.pathname, env)) {
|
||||
return json({ error: "rate_limited" }, 429, requestId, { "retry-after": "60" });
|
||||
}
|
||||
|
||||
const authError = await authorize(request, env);
|
||||
if (authError) return json({ error: authError.error }, authError.status, requestId);
|
||||
|
||||
if (await installRateLimited(request, url.pathname, env)) {
|
||||
return json({ error: "rate_limited" }, 429, requestId, { "retry-after": "60" });
|
||||
}
|
||||
|
||||
const maxBodyBytes = boundedPositiveInt(
|
||||
env.MAX_BODY_BYTES,
|
||||
DEFAULT_MAX_BODY_BYTES,
|
||||
1,
|
||||
HARD_MAX_BODY_BYTES,
|
||||
);
|
||||
const parsed = await readJsonObject(request, maxBodyBytes);
|
||||
if (!parsed.ok) return json({ error: parsed.error }, parsed.status, requestId);
|
||||
|
||||
switch (url.pathname) {
|
||||
case "/v1/events": {
|
||||
const maxEvents = boundedPositiveInt(env.MAX_EVENTS_PER_BATCH, DEFAULT_MAX_EVENTS, 1, 100);
|
||||
const normalized = normalizeEvents(parsed.value, maxEvents);
|
||||
if (!normalized.ok) {
|
||||
return json({ error: normalized.error }, normalized.status, requestId);
|
||||
}
|
||||
const result = await storeEvents(normalized.value, env);
|
||||
return json(
|
||||
{
|
||||
ok: true,
|
||||
id: result.id,
|
||||
stored: result.stored,
|
||||
duplicate: result.duplicate,
|
||||
},
|
||||
202,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
case "/v1/crashes": {
|
||||
const normalized = normalizeCrash(parsed.value);
|
||||
if (!normalized.ok) {
|
||||
return json({ error: normalized.error }, normalized.status, requestId);
|
||||
}
|
||||
const result = await storeCrash(normalized.value, env);
|
||||
return json(
|
||||
{
|
||||
ok: true,
|
||||
id: result.id,
|
||||
fingerprint: result.fingerprint,
|
||||
duplicate: result.duplicate,
|
||||
},
|
||||
202,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
case "/v1/bugs": {
|
||||
const normalized = normalizeBug(parsed.value);
|
||||
if (!normalized.ok) {
|
||||
return json({ error: normalized.error }, normalized.status, requestId);
|
||||
}
|
||||
const result = await storeBug(normalized.value, env);
|
||||
return json(
|
||||
{ ok: true, id: result.id, duplicate: result.duplicate },
|
||||
202,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "diagnostics request failed",
|
||||
requestId,
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
return json({ error: "internal" }, 500, requestId);
|
||||
}
|
||||
},
|
||||
|
||||
scheduled(
|
||||
controller: ScheduledController,
|
||||
env: DiagnosticsEnv,
|
||||
ctx: ExecutionContext,
|
||||
): void {
|
||||
ctx.waitUntil(
|
||||
runRetention(env).catch((error) => {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "diagnostics retention failed",
|
||||
scheduledTime: controller.scheduledTime,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
} satisfies ExportedHandler<DiagnosticsEnv>;
|
||||
|
||||
async function readiness(env: DiagnosticsEnv, requestId: string): Promise<Response> {
|
||||
if (!env.INGEST_KEY) {
|
||||
return json({ ok: false, error: "server_misconfigured" }, 503, requestId);
|
||||
}
|
||||
try {
|
||||
await env.DB.batch([
|
||||
env.DB.prepare(
|
||||
"SELECT id, received_at, install_id, payload_json FROM event_batches LIMIT 1",
|
||||
),
|
||||
env.DB.prepare(
|
||||
"SELECT id, occurred_at, stack_r2_key, breadcrumbs_json FROM crashes LIMIT 1",
|
||||
),
|
||||
env.DB.prepare(
|
||||
"SELECT id, occurred_at, logs_r2_key, device_json FROM bugs LIMIT 1",
|
||||
),
|
||||
]);
|
||||
return json({ ok: true, service: "vnidrop-diagnostics", schema: 1 }, 200, requestId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
message: "diagnostics readiness check failed",
|
||||
requestId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
return json({ ok: false, error: "dependency_unavailable" }, 503, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
async function authorize(
|
||||
request: Request,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<{ status: 401 | 503; error: "unauthorized" | "server_misconfigured" } | null> {
|
||||
const expected = env.INGEST_KEY;
|
||||
if (!expected) return { status: 503, error: "server_misconfigured" };
|
||||
const provided = request.headers.get("x-vnidrop-key") ?? "";
|
||||
if (!(await timingSafeEqual(provided, expected))) {
|
||||
return { status: 401, error: "unauthorized" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function sourceRateLimited(
|
||||
request: Request,
|
||||
path: string,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<boolean> {
|
||||
const source = request.headers.get("cf-connecting-ip")?.slice(0, 64) || "unknown";
|
||||
const result = await env.SOURCE_RATE_LIMITER.limit({ key: `${source}:${path}` });
|
||||
return !result.success;
|
||||
}
|
||||
|
||||
async function installRateLimited(
|
||||
request: Request,
|
||||
path: string,
|
||||
env: DiagnosticsEnv,
|
||||
): Promise<boolean> {
|
||||
const source = request.headers.get("cf-connecting-ip")?.slice(0, 64) || "unknown";
|
||||
const installId = request.headers.get("x-vnidrop-install-id")?.trim().slice(0, 80) || source;
|
||||
const result = await env.INSTALL_RATE_LIMITER.limit({ key: `${installId}:${path}` });
|
||||
return !result.success;
|
||||
}
|
||||
|
||||
function isIngestPath(path: string): path is "/v1/events" | "/v1/crashes" | "/v1/bugs" {
|
||||
return path === "/v1/events" || path === "/v1/crashes" || path === "/v1/bugs";
|
||||
}
|
||||
|
||||
async function timingSafeEqual(provided: string, expected: string): Promise<boolean> {
|
||||
const encoder = new TextEncoder();
|
||||
const [providedHash, expectedHash] = await Promise.all([
|
||||
crypto.subtle.digest("SHA-256", encoder.encode(provided)),
|
||||
crypto.subtle.digest("SHA-256", encoder.encode(expected)),
|
||||
]);
|
||||
return crypto.subtle.timingSafeEqual(providedHash, expectedHash);
|
||||
}
|
||||
|
||||
function json(
|
||||
body: unknown,
|
||||
status: number,
|
||||
requestId: string,
|
||||
extraHeaders?: Readonly<Record<string, string>>,
|
||||
): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: responseHeaders(requestId, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
...extraHeaders,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function responseHeaders(
|
||||
requestId: string,
|
||||
extraHeaders?: Readonly<Record<string, string>>,
|
||||
): Headers {
|
||||
const headers = new Headers(extraHeaders);
|
||||
headers.set("cache-control", "no-store");
|
||||
headers.set("x-content-type-options", "nosniff");
|
||||
headers.set("x-request-id", requestId);
|
||||
return headers;
|
||||
}
|
||||
|
||||
function boundedPositiveInt(
|
||||
raw: string | undefined,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
Reference in New Issue
Block a user