/* 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://@/ 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}`); } }