mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
268 lines
7.7 KiB
TypeScript
268 lines
7.7 KiB
TypeScript
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;
|
|
}
|