From d95a40a58dca83088d06018b9b7abd06aef370ef Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 30 Oct 2024 12:26:00 +0100 Subject: [PATCH 1/4] feat(core): Add tunnel server helper --- packages/core/src/index.ts | 1 + packages/core/src/tunnel.ts | 76 +++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 packages/core/src/tunnel.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 24cea1bea7ca..f1481f6d7deb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -108,6 +108,7 @@ export { getMetricSummaryJsonForSpan } from './metrics/metric-summary'; export { addTracingHeadersToFetchRequest, instrumentFetchRequest } from './fetch'; export { trpcMiddleware } from './trpc'; export { captureFeedback } from './feedback'; +export { handleTunnelEnvelope } from './tunnel'; // eslint-disable-next-line deprecation/deprecation export { getCurrentHubShim, getCurrentHub } from './getCurrentHubShim'; diff --git a/packages/core/src/tunnel.ts b/packages/core/src/tunnel.ts new file mode 100644 index 000000000000..2cf019275588 --- /dev/null +++ b/packages/core/src/tunnel.ts @@ -0,0 +1,76 @@ +import type { Client, Transport } from '@sentry/types'; +import { createEnvelope, dsnFromString, parseEnvelope } from '@sentry/utils'; +import { getEnvelopeEndpointWithUrlEncodedAuth } from './api'; +import { getClient } from './currentScopes'; + +interface HandleTunnelOptions { + /** + * A list of DSNs that are allowed to be passed through the server. + * + * Defaults to only server DSN. + */ + dsnAllowList?: string[]; + /** + * The client instance to use + * + * Defaults to the global instance. + */ + client?: Client; +} + +let CACHED_TRANSPORTS: Map | undefined; + +/** + * Handles envelopes sent from the browser client via the tunnel option. + */ +export async function handleTunnelEnvelope( + envelopeBytes: Uint8Array, + options: HandleTunnelOptions = {}, +): Promise { + const client = (options && options.client) || getClient(); + + if (!client) { + throw new Error('No server client'); + } + + const [headers, items] = parseEnvelope(envelopeBytes); + + if (!headers.dsn) { + throw new Error('DSN missing from envelope headers'); + } + + // If the DSN in the envelope headers matches the server DSN, we can send it directly. + const clientOptions = client.getOptions(); + if (headers.dsn === clientOptions.dsn) { + await client.sendEnvelope(createEnvelope(headers, items)); + return; + } + + if (!options.dsnAllowList || !options.dsnAllowList.includes(headers.dsn)) { + throw new Error('DSN does not match server DSN or allow list'); + } + + if (!CACHED_TRANSPORTS) { + CACHED_TRANSPORTS = new Map(); + } + + let transport = CACHED_TRANSPORTS.get(headers.dsn); + + if (!transport) { + const dsn = dsnFromString(headers.dsn); + if (!dsn) { + throw new Error('Invalid DSN in envelope headers'); + } + const url = getEnvelopeEndpointWithUrlEncodedAuth(dsn); + + const createTransport = clientOptions.transport; + transport = createTransport({ + ...clientOptions.transportOptions, + recordDroppedEvent: client.recordDroppedEvent.bind(client), + url, + }); + CACHED_TRANSPORTS.set(headers.dsn, transport); + } + + await transport.send(createEnvelope(headers, items)); +} From eeb2087779321f964445ba0ea89ab59d13181438 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 30 Oct 2024 21:27:43 +0100 Subject: [PATCH 2/4] Add test --- .../node-integration-tests/src/index.ts | 11 +++++-- .../suites/tunnel/child.mjs | 18 +++++++++++ .../suites/tunnel/server.mjs | 31 +++++++++++++++++++ .../suites/tunnel/test.ts | 24 ++++++++++++++ 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tunnel/child.mjs create mode 100644 dev-packages/node-integration-tests/suites/tunnel/server.mjs create mode 100644 dev-packages/node-integration-tests/suites/tunnel/test.ts diff --git a/dev-packages/node-integration-tests/src/index.ts b/dev-packages/node-integration-tests/src/index.ts index 4bd0a9ccce25..1e2d933e519e 100644 --- a/dev-packages/node-integration-tests/src/index.ts +++ b/dev-packages/node-integration-tests/src/index.ts @@ -25,12 +25,17 @@ export function loggingTransport(_options: BaseTransportOptions): Transport { * Setting this port to something specific is useful for local debugging but dangerous for * CI/CD environments where port collisions can cause flakes! */ -export function startExpressServerAndSendPortToRunner(app: Express, port: number | undefined = undefined): void { +export function startExpressServerAndSendPortToRunner( + app: Express, + port: number | undefined = undefined, + onPort?: (port: number) => void, +): void { const server = app.listen(port || 0, () => { const address = server.address() as AddressInfo; - + const actualPort = port || address.port; // eslint-disable-next-line no-console - console.log(`{"port":${port || address.port}}`); + console.log(`{"port":${actualPort}}`); + if (onPort) onPort(actualPort); }); } diff --git a/dev-packages/node-integration-tests/suites/tunnel/child.mjs b/dev-packages/node-integration-tests/suites/tunnel/child.mjs new file mode 100644 index 000000000000..abc377b5a7f3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/child.mjs @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/node'; + +console.log('Child process started'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tunnel: `http://localhost:${process.env.PORT}/tunnel`, + autoSessionTracking: false, + transportOptions: { + // I'm sure express.raw() can be made to work without this, but probably not worth trying to figure out how + headers:{ + "Content-Type": "application/octet-stream", + } + } +}); + +throw new Error('Test error in child process'); diff --git a/dev-packages/node-integration-tests/suites/tunnel/server.mjs b/dev-packages/node-integration-tests/suites/tunnel/server.mjs new file mode 100644 index 000000000000..6ae94d1a3071 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/server.mjs @@ -0,0 +1,31 @@ +import { fork } from 'child_process'; +import { join } from 'path'; +import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import { handleTunnelEnvelope } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +const __dirname = new URL('.', import.meta.url).pathname; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + autoSessionTracking: false, + transport: loggingTransport, +}); + +import express from 'express'; + +const app = express(); + +app.post('/tunnel', express.raw(), async (req, res) => { + await handleTunnelEnvelope(req.body); + res.sendStatus(200); +}); + +startExpressServerAndSendPortToRunner(app, undefined, port => { + const child = fork(join(__dirname, 'child.mjs'), { stdio: 'inherit', env: { ...process.env, PORT: port.toString() } }); + child.on('exit', code => { + console.log('Child process exited with code', code); + process.exit(code); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tunnel/test.ts b/dev-packages/node-integration-tests/suites/tunnel/test.ts new file mode 100644 index 000000000000..a94ebc1243d2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tunnel/test.ts @@ -0,0 +1,24 @@ +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +afterEach(() => { + cleanupChildProcesses(); +}); + +const EXPECTED_EVENT = { + exception: { + values: [ + { + type: 'Error', + value: 'Test error in child process', + }, + ], + }, +}; + +test('handleTunnelEnvelope should forward envelopes', done => { + createRunner(__dirname, 'server.mjs') + .expect({ + event: EXPECTED_EVENT, + }) + .start(done); +}); From d4713f801e9f1389768da70166256197bb46209f Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 30 Oct 2024 21:29:47 +0100 Subject: [PATCH 3/4] Lint --- .../node-integration-tests/suites/tunnel/child.mjs | 8 ++++---- .../node-integration-tests/suites/tunnel/server.mjs | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tunnel/child.mjs b/dev-packages/node-integration-tests/suites/tunnel/child.mjs index abc377b5a7f3..c5d659cbf9c9 100644 --- a/dev-packages/node-integration-tests/suites/tunnel/child.mjs +++ b/dev-packages/node-integration-tests/suites/tunnel/child.mjs @@ -9,10 +9,10 @@ Sentry.init({ autoSessionTracking: false, transportOptions: { // I'm sure express.raw() can be made to work without this, but probably not worth trying to figure out how - headers:{ - "Content-Type": "application/octet-stream", - } - } + headers: { + 'Content-Type': 'application/octet-stream', + }, + }, }); throw new Error('Test error in child process'); diff --git a/dev-packages/node-integration-tests/suites/tunnel/server.mjs b/dev-packages/node-integration-tests/suites/tunnel/server.mjs index 6ae94d1a3071..ecbefb10708b 100644 --- a/dev-packages/node-integration-tests/suites/tunnel/server.mjs +++ b/dev-packages/node-integration-tests/suites/tunnel/server.mjs @@ -23,7 +23,10 @@ app.post('/tunnel', express.raw(), async (req, res) => { }); startExpressServerAndSendPortToRunner(app, undefined, port => { - const child = fork(join(__dirname, 'child.mjs'), { stdio: 'inherit', env: { ...process.env, PORT: port.toString() } }); + const child = fork(join(__dirname, 'child.mjs'), { + stdio: 'inherit', + env: { ...process.env, PORT: port.toString() }, + }); child.on('exit', code => { console.log('Child process exited with code', code); process.exit(code); From 96429cb88c67fdc87737ed7bd9318185daf13e78 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Fri, 21 Feb 2025 16:25:35 +0100 Subject: [PATCH 4/4] Lint and fix build --- dev-packages/node-integration-tests/src/index.ts | 2 +- packages/core/src/tunnel.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/dev-packages/node-integration-tests/src/index.ts b/dev-packages/node-integration-tests/src/index.ts index f97271c0d34c..4e1986b2a9b8 100644 --- a/dev-packages/node-integration-tests/src/index.ts +++ b/dev-packages/node-integration-tests/src/index.ts @@ -34,7 +34,7 @@ export function startExpressServerAndSendPortToRunner( const address = server.address() as AddressInfo; // @ts-expect-error If we write the port to the app we can read it within route handlers in tests - const actualPort = app.port = port || address.port; + const actualPort = (app.port = port || address.port); // eslint-disable-next-line no-console console.log(`{"port":${actualPort}}`); diff --git a/packages/core/src/tunnel.ts b/packages/core/src/tunnel.ts index 2cf019275588..407bfe389988 100644 --- a/packages/core/src/tunnel.ts +++ b/packages/core/src/tunnel.ts @@ -1,7 +1,9 @@ -import type { Client, Transport } from '@sentry/types'; -import { createEnvelope, dsnFromString, parseEnvelope } from '@sentry/utils'; import { getEnvelopeEndpointWithUrlEncodedAuth } from './api'; +import type { Client } from './client'; import { getClient } from './currentScopes'; +import type { Transport } from './types-hoist/transport'; +import { dsnFromString } from './utils-hoist/dsn'; +import { createEnvelope, parseEnvelope } from './utils-hoist/envelope'; interface HandleTunnelOptions { /** @@ -27,7 +29,7 @@ export async function handleTunnelEnvelope( envelopeBytes: Uint8Array, options: HandleTunnelOptions = {}, ): Promise { - const client = (options && options.client) || getClient(); + const client = options?.client || getClient(); if (!client) { throw new Error('No server client'); @@ -46,7 +48,7 @@ export async function handleTunnelEnvelope( return; } - if (!options.dsnAllowList || !options.dsnAllowList.includes(headers.dsn)) { + if (!options.dsnAllowList?.includes(headers.dsn)) { throw new Error('DSN does not match server DSN or allow list'); }