diff --git a/packages/mockotlpserver/lib/cli.js b/packages/mockotlpserver/lib/cli.js index 2a604c7e..d633d5bb 100755 --- a/packages/mockotlpserver/lib/cli.js +++ b/packages/mockotlpserver/lib/cli.js @@ -118,6 +118,11 @@ const OPTIONS = [ type: 'bool', help: 'Start a web server to inspect traces with some charts.', }, + { + names: ['tunnel', 't'], + type: 'string', + help: `Tunnel all incoming requests to the given server. Only supported for the HTTP OTLP server (port 4318). Received OTLP data will still be printed per the '-o' option.`, + }, ]; async function main() { @@ -165,6 +170,7 @@ async function main() { services, grpcHostname: opts.hostname || DEFAULT_HOSTNAME, httpHostname: opts.hostname || DEFAULT_HOSTNAME, + tunnel: opts.tunnel, uiHostname: opts.hostname || DEFAULT_HOSTNAME, }); await otlpServer.start(); diff --git a/packages/mockotlpserver/lib/http.js b/packages/mockotlpserver/lib/http.js index 862f6b43..e553e69b 100644 --- a/packages/mockotlpserver/lib/http.js +++ b/packages/mockotlpserver/lib/http.js @@ -27,6 +27,7 @@ const { } = require('./diagch'); const {getProtoRoot} = require('./proto'); const {Service} = require('./service'); +const {createHttpTunnel} = require('./tunnel'); const protoRoot = getProtoRoot(); @@ -125,6 +126,7 @@ class HttpService extends Service { * @param {import('./luggite').Logger} opts.log * @param {string} opts.hostname * @param {number} opts.port + * @param {string} [opts.tunnel] */ constructor(opts) { super(); @@ -133,10 +135,16 @@ class HttpService extends Service { } async start() { - const {log, hostname, port} = this._opts; + const {log, hostname, port, tunnel} = this._opts; + const httpTunnel = tunnel && createHttpTunnel(log, tunnel); + this._server = http.createServer((req, res) => { const contentType = req.headers['content-type']; - if (!parsersMap[contentType]) { + + // Tunnel requests if defined or validate otherwise + if (httpTunnel) { + httpTunnel(req, res); + } else if (!parsersMap[contentType]) { return badRequest( res, `unexpected request Content-Type: "${contentType}"` @@ -146,26 +154,29 @@ class HttpService extends Service { const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); req.on('end', () => { - // TODO: send back the proper error - if (chunks.length === 0) { - return badRequest(res); - } - - // TODO: in future response may add some header to communicate back - // some information about - // - the collector - // - the config - // - something else - // PS: maybe collector could be able to tell the sdk/distro to stop sending - // because of: high load, sample rate changed, et al?? - let resBody = null; - if (contentType === 'application/json') { - resBody = JSON.stringify({ - ok: 1, - }); + // Provide a response if there is no tunnel + if (!httpTunnel) { + // TODO: send back the proper error + if (chunks.length === 0) { + return badRequest(res); + } + + // TODO: in future response may add some header to communicate back + // some information about + // - the collector + // - the config + // - something else + // PS: maybe collector could be able to tell the sdk/distro to stop sending + // because of: high load, sample rate changed, et al?? + let resBody = null; + if (contentType === 'application/json') { + resBody = JSON.stringify({ + ok: 1, + }); + } + res.writeHead(200); + res.end(resBody); } - res.writeHead(200); - res.end(resBody); // We publish into diagnostics channel after returning a response to the client to avoid not returning // a response if one of the handlers throws (the hanlders run synchronously in the diff --git a/packages/mockotlpserver/lib/mockotlpserver.js b/packages/mockotlpserver/lib/mockotlpserver.js index 8a69e325..73bc892c 100644 --- a/packages/mockotlpserver/lib/mockotlpserver.js +++ b/packages/mockotlpserver/lib/mockotlpserver.js @@ -71,6 +71,7 @@ class MockOtlpServer { * @param {number} [opts.httpPort] Default 4318. Use 0 to select a free port. * @param {string} [opts.grpcHostname] Default 'localhost'. * @param {number} [opts.grpcPort] Default 4317. Use 0 to select a free port. + * @param {string} [opts.tunnel] Default undefined. * @param {string} [opts.uiHostname] Default 'localhost'. * @param {number} [opts.uiPort] Default 8080. Use 0 to select a free port. * @param {Function} [opts.onTrace] Called for each received trace service request. @@ -86,6 +87,7 @@ class MockOtlpServer { this._services = opts.services ?? ['http', 'grpc', 'ui']; this._httpHostname = opts.httpHostname ?? DEFAULT_HOSTNAME; this._httpPort = opts.httpPort ?? DEFAULT_HTTP_PORT; + this._tunnel = opts.tunnel; this._grpcHostname = opts.grpcHostname ?? DEFAULT_HOSTNAME; this._grpcPort = opts.grpcPort ?? DEFAULT_GRPC_PORT; this._uiHostname = opts.uiHostname ?? DEFAULT_HOSTNAME; @@ -118,6 +120,7 @@ class MockOtlpServer { log: this._log, hostname: this._httpHostname, port: this._httpPort, + tunnel: this._tunnel, }); await this._httpService.start(); this.httpUrl = this._httpService.url; diff --git a/packages/mockotlpserver/lib/tunnel.js b/packages/mockotlpserver/lib/tunnel.js new file mode 100644 index 00000000..4f03e734 --- /dev/null +++ b/packages/mockotlpserver/lib/tunnel.js @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const http = require('http'); +const https = require('https'); +/** + * @param {import('./luggite').Logger} log + * @param {string} target + * @returns {((req: http.IncomingMessage, res: http.ServerResponse) => void) | undefined} + */ +function createHttpTunnel(log, target) { + /** @type {URL} */ + let targetUrl; + + try { + targetUrl = new URL(target); + } catch { + log.warn( + `Cannot create a tunnel to target "${target}". The given URL is invalid.` + ); + return; + } + + const {protocol, host} = targetUrl; + if (protocol !== 'http:' && protocol !== 'https:') { + log.warn( + `Cannot create a tunnel to target "${target}". Protocol must be one of: http, https.` + ); + return; + } + + const port = targetUrl.port || (protocol === 'http:' ? 80 : 443); + return function httpTunnel(req, res) { + // APM server does not support 'http/json' protocol + // ref: https://www.elastic.co/guide/en/observability/current/apm-api-otlp.html + const contentType = req.headers['content-type']; + if (contentType !== 'application/x-protobuf') { + log.warn( + `Content type "${contentType}" may not be accepted by the target server (${target})` + ); + } + + const httpFlavor = protocol === 'http:' ? http : https; + const options = { + host: targetUrl.host, + port, + method: req.method, + headers: {...req.headers, host}, + path: req.url, + }; + const tunnelReq = httpFlavor.request(options, (tunnelRes) => { + tunnelRes.pipe(res); + }); + req.pipe(tunnelReq); + }; +} + +module.exports = { + createHttpTunnel, +};