Files
source/crash-telemetry/worker.js
T
58e00e0d66 Add the crash-telemetry sink, a Cloudflare Worker, to the repo
The other half of the feature, and the half that decides whether a player's report
survives: the endpoint CRASH_TELEMETRY_URL points at. It takes the POST from
processCrashTelemetry and forwards the report to a Discord webhook as a file
attachment. It stores nothing -- a report is only worth reading next to the PDB it
was built against, and that never leaves a developer's machine, so there is nothing
for a bucket to do here that the channel we already read bug reports in does not do
better. That also keeps the whole thing inside the Workers free tier, where the
10 ms budget is CPU, not wall clock, and waiting on Discord is not metered.

In the repo rather than in someone's home directory because the status codes are a
contract with the client and the two have to be changed together. reportIsSettled()
in sgp/crash_telemetry.cpp deletes the player's copy on 2xx and on 400/413/415 and
keeps it on everything else, so a settling 4xx returned for a failure on our side
silently destroys the report. Every failure path here is therefore a 503, each
naming its own cause in wrangler's console, and the one 4xx that is safe -- 429,
which the client does not settle -- is the one the rate limiter returns.

That rate limiter is a binding with a .limit() call, not a dashboard rule: WAF rate
limiting rules need a zone and a workers.dev subdomain is not one. Per-IP, 50 a
minute, which has to clear kMaxUploadsPerRun (20) in the client or a player draining
a backlog throttles themselves. It is checked before the body is read.

A report is attacker-controlled text arriving at a public, unauthenticated endpoint
whose URL ships in every player's Ja2.ini, so the summary line strips markdown from
the player handle and the payload sets allowed_mentions to nothing. The size cap and
the "*** CRASH" check keep drive-by scanners out; anything determined gets through,
and the blast radius is a message we delete.

test.mjs covers the whole contract against a stubbed fetch, no network and no
webhook needed. DISCORD_WEBHOOK is a secret and lives nowhere in this tree;
.dev.vars, which holds a live one for local development, is gitignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 10:25:47 -03:00

85 lines
4.3 KiB
JavaScript

// Crash-telemetry sink for JA2 1.13. Takes the POST that sgp::processCrashTelemetry
// makes, and forwards the report to a Discord webhook as a file attachment.
//
// It stores nothing. Reports are only useful next to a PDB, and the PDB lives on a
// developer's machine, so there is nothing for a bucket to do here that the channel
// we already read bug reports in does not do better.
//
// The status codes matter — the client acts on them (reportIsSettled()):
// 2xx accepted; the client deletes its copy
// 400 junk, never going to be accepted; the client deletes its copy
// 429 throttled; the client keeps the file and retries next launch
// 5xx our problem; the client keeps the file and retries next launch
// So never answer a settling 4xx for a failure on our side: that throws the report
// away. 429 is the one 4xx that is safe, because the client does not settle it.
const MAX_BYTES = 64 * 1024; // reports run 2-8 KB; anything near this is not ours
// Pull the header fields the client writes, for a one-line channel summary.
// See writeExceptionBacktrace(): "*** CRASH code=... ***", then " <key> <value>".
function summarize(text) {
const field = (name) => (text.match(new RegExp(`^\\s+${name} (.+)$`, "m")) || [])[1]?.trim();
const code = (text.match(/code=(\w+)/) || [])[1] || "????????";
const av = (text.match(/access violation: (.+)/) || [])[1]?.trim();
const parts = [`\`${code}\`${av ? ` (${av})` : ""}`];
const build = field("build");
const handle = field("handle");
if (build) parts.push(`build \`${build}\``);
if (handle) parts.push(`from **${handle.replace(/[`*_~|@]/g, "")}**`); // no pings, no markdown
return parts.join(" · ");
}
export default {
async fetch(request, env) {
if (request.method !== "POST") return new Response("POST only\n", { status: 405 });
if (!env.DISCORD_WEBHOOK) {
// `wrangler secret put` targets the deployed Worker; `wrangler dev` reads
// .dev.vars instead. Missing one of the two is the usual cause of a 503.
console.error("DISCORD_WEBHOOK unset (deploy: wrangler secret put, dev: .dev.vars)");
return new Response("not configured\n", { status: 503 });
}
// Per-IP cap, checked before the body is read so a flood costs nothing. 429 is
// deliberate: reportIsSettled() does not settle it, so a throttled player keeps
// the report and it goes out next launch. Answering 400 here would delete it.
const ip = request.headers.get("cf-connecting-ip") || "unknown";
const { success } = await env.UPLOAD_LIMITER.limit({ key: ip });
if (!success) return new Response("slow down\n", { status: 429 });
// Trust the declared length only as a cheap early out; the real cap is on the
// bytes actually read, since Content-Length can lie or be absent.
const declared = Number(request.headers.get("content-length") || 0);
if (declared > MAX_BYTES) return new Response("too large\n", { status: 400 });
const text = await request.text();
if (text.length > MAX_BYTES) return new Response("too large\n", { status: 400 });
// The endpoint is public and unauthenticated — the URL ships in every player's
// Ja2.ini. This is not security, just a filter that keeps drive-by POSTs and
// scanners out of the channel. Anything determined gets through; that is fine,
// the blast radius is a message we delete.
if (!text.includes("*** CRASH")) return new Response("not a crash report\n", { status: 400 });
const stamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15);
const form = new FormData();
form.append("payload_json", JSON.stringify({
content: summarize(text),
allowed_mentions: { parse: [] }, // a report is attacker-controlled text; never ping
}));
form.append("files[0]", new Blob([text], { type: "text/plain" }), `crash_${stamp}.txt`);
let res;
try {
res = await fetch(env.DISCORD_WEBHOOK, { method: "POST", body: form });
} catch (e) {
console.error("discord unreachable:", e.message);
return new Response("upstream unreachable\n", { status: 503 });
}
// Discord's own 429 included: not the player's fault, so keep the report alive.
if (!res.ok) {
console.error("discord rejected:", res.status, (await res.text()).slice(0, 200));
return new Response("upstream rejected\n", { status: 503 });
}
return new Response(null, { status: 204 });
},
};