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>
This commit is contained in:
Marco Antonio J. Costa
2026-07-28 10:25:47 -03:00
committed by majcosta
co-authored by Claude Opus 5
parent f5782dccbd
commit 58e00e0d66
7 changed files with 298 additions and 0 deletions
+8
View File
@@ -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/<id>/<token>"
+7
View File
@@ -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/
+101
View File
@@ -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.<subdomain>.workers.dev` into
`gamedir/Ja2.ini`:
```ini
[Ja2 Settings]
CRASH_TELEMETRY_URL = https://ja2-crash-telemetry.<subdomain>.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.
+9
View File
@@ -0,0 +1,9 @@
{
"name": "ja2-crash-telemetry",
"private": true,
"type": "module",
"scripts": {
"test": "node test.mjs",
"deploy": "wrangler deploy"
}
}
+62
View File
@@ -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");
+84
View File
@@ -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 " <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 });
},
};
+27
View File
@@ -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)