GlitchTip is behind Cloudflare, which answers the browser's cross-site ingest POSTs with 503 (no CORS headers -> 'Failed to fetch'); server-side requests pass. The browser SDK now posts envelopes same-origin to /sentry-tunnel, and the server forwards them to GlitchTip: - imports/startup/server/sentryTunnel.js: WebApp handler that relays the raw envelope to <dsn>/api/<project>/envelope/?sentry_key=<key> over IPv4 (family:4 avoids Happy-Eyeballs picking Cloudflare's unroutable IPv6 on IPv4-only hosts). GlitchTip authenticates by the sentry_key query, so the key is derived from the DSN and passed explicitly. - client ravenLogger: tunnel: '/sentry-tunnel'. Verified: POST /sentry-tunnel -> 200 (envelope reaches GlitchTip).
127 lines
4.4 KiB
JavaScript
127 lines
4.4 KiB
JavaScript
/* eslint-disable import/no-absolute-path */
|
|
import https from 'https';
|
|
import { Meteor } from 'meteor/meteor';
|
|
import { WebApp } from 'meteor/webapp';
|
|
|
|
/*
|
|
* Sentry tunnel: /sentry-tunnel receives the browser SDK's envelopes
|
|
* same-origin and forwards them to GlitchTip server-side.
|
|
*
|
|
* Why: GlitchTip lives behind Cloudflare (sentry.comunes.org). Cloudflare's
|
|
* bot protection answers the browser's cross-site ingest POSTs with 503 (no
|
|
* CORS headers -> "Failed to fetch"), while identical server-side requests get
|
|
* 200. Routing events through our own origin avoids the cross-site request
|
|
* (and ad-blockers) completely. The browser SDK opts in via `tunnel` in
|
|
* imports/startup/client/ravenLogger.js.
|
|
*
|
|
* Single-DSN app: the target is derived from sentryPublicDSN, so we never have
|
|
* to decompress/parse the envelope body — we just relay the raw bytes to the
|
|
* right project's ingest endpoint.
|
|
*/
|
|
|
|
const MAX_BODY = 1024 * 1024; // 1 MB: Sentry envelopes are small; cap abuse.
|
|
|
|
function ingestTargetFromDsn(dsn) {
|
|
// DSN: https://<publicKey>@<host>/<projectId>
|
|
const u = new URL(dsn);
|
|
const projectId = u.pathname.replace(/^\//, '');
|
|
const publicKey = u.username;
|
|
if (!projectId || !publicKey) throw new Error('DSN missing project id or public key');
|
|
// GlitchTip authenticates the ingest by the `sentry_key` query param; when the
|
|
// SDK tunnels, that key lives inside the envelope header, which GlitchTip does
|
|
// not read here -> it would answer 403 "Denied". Pass the key explicitly.
|
|
const path = `/api/${projectId}/envelope/?sentry_key=${publicKey}&sentry_version=7`;
|
|
return { hostname: u.hostname, port: u.port || 443, path };
|
|
}
|
|
|
|
/*
|
|
* Forward the raw envelope to GlitchTip over IPv4 (`family: 4`). GlitchTip is
|
|
* behind Cloudflare, which is dual-stack; on IPv4-only hosts Node's fetch/
|
|
* Happy-Eyeballs may pick the unroutable IPv6 and fail. Cloudflare always has
|
|
* IPv4, so pinning the family is safe everywhere and avoids that flakiness.
|
|
*/
|
|
function forwardToIngest(target, headers, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const upstream = https.request({
|
|
hostname: target.hostname,
|
|
port: target.port,
|
|
path: target.path,
|
|
method: 'POST',
|
|
family: 4,
|
|
headers
|
|
}, (r) => {
|
|
r.resume(); // drain the response
|
|
if (r.statusCode >= 400) {
|
|
console.error(`sentry-tunnel: upstream returned ${r.statusCode}`);
|
|
}
|
|
resolve(r.statusCode);
|
|
});
|
|
upstream.on('error', reject);
|
|
upstream.end(body);
|
|
});
|
|
}
|
|
|
|
const publicDsn = Meteor.settings.public && Meteor.settings.public.sentryPublicDSN;
|
|
|
|
if (publicDsn) {
|
|
let ingestTarget;
|
|
try {
|
|
ingestTarget = ingestTargetFromDsn(publicDsn);
|
|
} catch (e) {
|
|
console.error(`sentry-tunnel: invalid sentryPublicDSN, tunnel disabled: ${e.message}`);
|
|
}
|
|
|
|
if (ingestTarget) {
|
|
WebApp.connectHandlers.use('/sentry-tunnel', (req, res) => {
|
|
if (req.method !== 'POST') {
|
|
res.writeHead(405, { Allow: 'POST' });
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
const chunks = [];
|
|
let size = 0;
|
|
let aborted = false;
|
|
|
|
req.on('data', (chunk) => {
|
|
if (aborted) return;
|
|
size += chunk.length;
|
|
if (size > MAX_BODY) {
|
|
aborted = true;
|
|
res.writeHead(413);
|
|
res.end();
|
|
req.destroy();
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
|
|
req.on('end', async () => {
|
|
if (aborted) return;
|
|
try {
|
|
const body = Buffer.concat(chunks);
|
|
const headers = { 'Content-Type': 'application/x-sentry-envelope' };
|
|
if (req.headers['content-encoding']) {
|
|
headers['Content-Encoding'] = req.headers['content-encoding'];
|
|
}
|
|
// Preserve the reporter's IP so GlitchTip attributes the event correctly.
|
|
const fwd = req.headers['x-forwarded-for'];
|
|
headers['X-Forwarded-For'] = fwd
|
|
? `${fwd}, ${req.socket.remoteAddress}`
|
|
: req.socket.remoteAddress;
|
|
|
|
const status = await forwardToIngest(ingestTarget, headers, body);
|
|
res.writeHead(status);
|
|
res.end();
|
|
} catch (err) {
|
|
// Never let a reporting failure surface to the user; just log it.
|
|
console.error(`sentry-tunnel: forward failed: ${err.message}`);
|
|
res.writeHead(502);
|
|
res.end();
|
|
}
|
|
});
|
|
});
|
|
|
|
console.log(`sentry-tunnel enabled -> https://${ingestTarget.hostname}${ingestTarget.path}`);
|
|
}
|
|
}
|