diff --git a/crash-telemetry/.dev.vars.example b/crash-telemetry/.dev.vars.example new file mode 100644 index 000000000..0dfe032dd --- /dev/null +++ b/crash-telemetry/.dev.vars.example @@ -0,0 +1,8 @@ +# Copy to .dev.vars for `wrangler dev`. The deployed Worker does NOT read this +# file — set that one with `wrangler secret put DISCORD_WEBHOOK`. +# +# There is no stub value that works: a webhook URL you do not own answers 401 or +# 404, which the Worker correctly turns into a 503. Use a real webhook, pointed at +# a throwaway channel rather than the live one. .dev.vars therefore holds a live +# credential and is gitignored — keep it that way. +DISCORD_WEBHOOK="https://discord.com/api/webhooks//" diff --git a/crash-telemetry/.gitignore b/crash-telemetry/.gitignore new file mode 100644 index 000000000..a91bea875 --- /dev/null +++ b/crash-telemetry/.gitignore @@ -0,0 +1,7 @@ +# Holds a live Discord webhook URL. Never commit it. +.dev.vars + +# Regenerable local state: build cache, miniflare storage, account cache. +.wrangler/ + +node_modules/ diff --git a/crash-telemetry/README.md b/crash-telemetry/README.md new file mode 100644 index 000000000..45ecc1113 --- /dev/null +++ b/crash-telemetry/README.md @@ -0,0 +1,101 @@ +# Crash telemetry sink + +Receives the crash reports `sgp::processCrashTelemetry` uploads and posts them to a +Discord channel. Stores nothing — a report is only useful next to the matching PDB, +which lives on a developer's machine. + +## Deploy + +```sh +npm i -g wrangler # once +wrangler login # once +wrangler deploy # creates the Worker +wrangler secret put DISCORD_WEBHOOK # paste the channel's webhook URL +``` + +Deploy first: `secret put` needs the Worker to already exist. To point it at a +different channel later, run `secret put` again — it overwrites in place and +redeploys, no other step needed. Secrets are write-only; `wrangler secret list` +shows names, never values. + +Then put the printed `https://ja2-crash-telemetry..workers.dev` into +`gamedir/Ja2.ini`: + +```ini +[Ja2 Settings] +CRASH_TELEMETRY_URL = https://ja2-crash-telemetry..workers.dev +``` + +Empty or missing key = telemetry off, reports just accumulate locally. + +## Test without the game + +```sh +node test.mjs # status-code contract, Discord payload shape. No network. +``` + +Against a real local instance: + +```sh +cp .dev.vars.example .dev.vars # put a webhook URL in it, see below +wrangler dev # local, http://localhost:8787 + +printf '*** CRASH code=C0000005 ***\r\n build test\r\n' \ + | curl -X POST --data-binary @- localhost:8787 +``` + +Expect `204` for that, `400` for junk (`curl -X POST -d hello localhost:8787`). Any +real `crash_report_*.txt` out of a gamedir works as a body too. + +**`wrangler dev` does not see `wrangler secret put`.** Secrets set that way go to the +deployed Worker; local dev reads `.dev.vars` and nothing else. Without it every +request answers `503 not configured`. Each 503 names its own cause in the response +body and in wrangler's console. + +There is no stub webhook value that returns 2xx — a Discord webhook URL you do not +own answers 401 or 404, which the Worker correctly turns into a 503. So `.dev.vars` +needs a real webhook; point it at a throwaway channel rather than the live one. +`node test.mjs` is the path that needs no webhook at all. + +## Status codes are a contract + +The client (`reportIsSettled()` in `sgp/crash_telemetry.cpp`) deletes its copy of a +report on 2xx and on 400/413/415, and keeps it on everything else. So: + +| Status | Meaning | Client does | +| --- | --- | --- | +| 2xx | delivered | deletes its copy | +| 400 | not a crash report, or too big | deletes its copy | +| 429 | throttled | keeps it, retries next launch | +| 5xx | our fault (Discord down, rate-limited, secret unset) | keeps it, retries next launch | + +**Never answer a settling 4xx for a failure on our side** — that silently destroys +the report. 429 is the one 4xx that is safe here, precisely because the client does +not settle it. + +## Rate limiting + +A per-IP cap, via the `UPLOAD_LIMITER` binding in `wrangler.toml`, checked before +the body is read. The ceiling (50/minute) has to clear `kMaxUploadsPerRun` (20) in +the client, or a player draining a backlog throttles themselves. + +This has to be a binding with a `.limit()` call in `worker.js`, not a dashboard +rule: WAF rate limiting rules need a zone, and a `workers.dev` subdomain is not +one. The counter is per-colo and best-effort, so treat the number as a rough +ceiling — it is there to keep scanners out of the channel, not to be exact. + +Adding the binding from the Cloudflare dashboard instead would leave `wrangler.toml` +out of sync, and the next `wrangler deploy` would drop it. Edit the file. + +## Free tier + +100k requests/day and 10 ms CPU per invocation. Forwarding a few KB costs well under +a millisecond of CPU — the time spent waiting on Discord is not metered. + +## Not done + +No authentication. The endpoint is public and its URL ships in every player's +`Ja2.ini`, so assume it will eventually be found; the size and `*** CRASH` checks +only keep out drive-by scanners. Report contents are attacker-controlled text, which +is why the summary line strips markdown from the handle and sends +`allowed_mentions: {parse: []}`. Blast radius of abuse is a message we delete. diff --git a/crash-telemetry/package.json b/crash-telemetry/package.json new file mode 100644 index 000000000..42018e1f5 --- /dev/null +++ b/crash-telemetry/package.json @@ -0,0 +1,9 @@ +{ + "name": "ja2-crash-telemetry", + "private": true, + "type": "module", + "scripts": { + "test": "node test.mjs", + "deploy": "wrangler deploy" + } +} diff --git a/crash-telemetry/test.mjs b/crash-telemetry/test.mjs new file mode 100644 index 000000000..cf06f9f14 --- /dev/null +++ b/crash-telemetry/test.mjs @@ -0,0 +1,62 @@ +// node test.mjs — exercises the status-code contract the client depends on. +import assert from "node:assert"; +import worker from "./worker.js"; + +const REPORT = ` +*** CRASH code=C0000005 eip=0071D5A0 esp=202BF99C ebp=202BFA18 *** + time 2026-07-26 10:41:02 UTC + build 6a941c06 + handle @marco*evil + access violation: read from 00000002 + [0] 0071D5A0 + [1] 006BE7DE +`; + +let sent = null; // what we handed Discord on the last call +let upstream = () => new Response(null, { status: 204 }); +globalThis.fetch = async (url, init) => { sent = init; return upstream(); }; + +let throttled = false; +const ENV = { + DISCORD_WEBHOOK: "https://discord.test/hook", + UPLOAD_LIMITER: { limit: async () => ({ success: !throttled }) }, +}; + +const post = (body, env = ENV) => + worker.fetch(new Request("https://x/", { method: "POST", body }), env); + +// happy path +let r = await post(REPORT); +assert.equal(r.status, 204); +const content = JSON.parse(sent.body.get("payload_json")).content; +assert.match(content, /C0000005/); +assert.match(content, /read from 00000002/); +assert.match(content, /build `6a941c06`/); +assert.match(content, /marcoevil/); // markdown and @ stripped from the handle +assert.deepEqual(JSON.parse(sent.body.get("payload_json")).allowed_mentions, { parse: [] }); +assert.equal(await sent.body.get("files[0]").text(), REPORT); + +// junk: client should delete these, so they must be 400 +assert.equal((await post("hello")).status, 400); +assert.equal((await post("x".repeat(64 * 1024 + 1))).status, 400); + +// throttled: 429, and nothing reaches Discord. reportIsSettled() leaves 429 +// unsettled, so the client keeps the report for next launch. +throttled = true; +sent = null; +assert.equal((await post(REPORT)).status, 429); +assert.equal(sent, null); +throttled = false; + +// our failures: client must keep the report, so these must be 5xx +upstream = () => new Response(null, { status: 429 }); // Discord rate limit +assert.equal((await post(REPORT)).status, 503); +upstream = () => { throw new Error("network"); }; +assert.equal((await post(REPORT)).status, 503); +upstream = () => new Response(null, { status: 204 }); +assert.equal((await post(REPORT, {})).status, 503); // secret not set + +// wrong method +assert.equal((await worker.fetch(new Request("https://x/"), {})).status, 405); + +console.log("ok"); diff --git a/crash-telemetry/worker.js b/crash-telemetry/worker.js new file mode 100644 index 000000000..e4614c6fd --- /dev/null +++ b/crash-telemetry/worker.js @@ -0,0 +1,84 @@ +// 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 " ". +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 }); + }, +}; diff --git a/crash-telemetry/wrangler.toml b/crash-telemetry/wrangler.toml new file mode 100644 index 000000000..7c06bfcc0 --- /dev/null +++ b/crash-telemetry/wrangler.toml @@ -0,0 +1,27 @@ +name = "ja2-crash-telemetry" +main = "worker.js" +compatibility_date = "2025-07-01" + +# The workers.dev subdomain is the endpoint players POST to — there is no custom +# domain. Stated explicitly so the default can't move; wrangler warns otherwise. +workers_dev = true + +# No per-version preview URLs. Each one is another live, public endpoint that +# forwards to the Discord webhook, and testing happens against `wrangler dev` +# with the local sink, so they would be surface area for nothing. +preview_urls = false + +# Per-IP upload cap. The ceiling has to clear kMaxUploadsPerRun (20) in the client, +# or a player draining a backlog throttles themselves; 50/minute leaves headroom +# without letting a scanner flood the channel. period accepts only 10 or 60. +[[ratelimits]] +name = "UPLOAD_LIMITER" +namespace_id = "1001" + + [ratelimits.simple] + limit = 50 + period = 60 + +# DISCORD_WEBHOOK is a secret, not a var — it never belongs in this file: +# wrangler secret put DISCORD_WEBHOOK (deployed Worker) +# .dev.vars (wrangler dev)