UNPKG

@stuntman/server

Version:

Stuntman - HTTP proxy / mock server with API

393 lines 17.4 kB
import { request as fetchRequest } from 'undici'; import https from 'https'; import express from 'express'; import { v4 as uuidv4 } from 'uuid'; import { getRuleExecutor } from './ruleExecutor.js'; import { getTrafficStore } from './storage.js'; import { RawHeaders, logger, HttpCode, naiveGQLParser, escapeStringRegexp, errorToLog, HTTP_METHODS } from '@stuntman/shared'; import { RequestContext } from './requestContext.js'; import { IPUtils } from './ipUtils.js'; import { API } from './api/api.js'; // TODO add proper web proxy mode export class Mock { mockUuid; options; mockApp = null; MOCK_DOMAIN_REGEX; URL_PORT_REGEX; server = null; serverHttps = null; trafficStore; ipUtils = null; _api = null; get apiServer() { if (this.options.api.disabled) { return null; } if (!this._api) { this._api = new API({ ...this.options.api, mockUuid: this.mockUuid }, this.options.webgui); } return this._api; } get ruleExecutor() { return getRuleExecutor(this.mockUuid); } constructor(options) { this.mockUuid = uuidv4(); this.options = options; if (this.options.mock.httpsPort && (!this.options.mock.httpsKey || !this.options.mock.httpsCert)) { throw new Error('missing https key/cert'); } this.MOCK_DOMAIN_REGEX = new RegExp(`(?:\\.([0-9]+))?\\.(?:(?:${this.options.mock.domain}(https?)?)|(?:localhost))(:${this.options.mock.port}${this.options.mock.httpsPort ? `|${this.options.mock.httpsPort}` : ''})?(?:\\b|$)`, 'i'); this.URL_PORT_REGEX = new RegExp(`^(https?:\\/\\/[^:/]+):(?:${this.options.mock.port}${this.options.mock.httpsPort ? `|${this.options.mock.httpsPort}` : ''})(\\/.*)`, 'i'); this.trafficStore = getTrafficStore(this.mockUuid, this.options.storage.traffic); this.ipUtils = !this.options.mock.externalDns || this.options.mock.externalDns.length === 0 ? null : new IPUtils({ mockUuid: this.mockUuid, externalDns: this.options.mock.externalDns }); this.requestHandler = this.requestHandler.bind(this); this.bindRequestContext = this.bindRequestContext.bind(this); this.errorHandler = this.errorHandler.bind(this); } extractJwt(req) { try { const authorizationHeaderIndex = req.rawHeaders.findIndex((header) => header.toLowerCase() === 'authorization'); if (authorizationHeaderIndex < 0) { return { result: false, description: 'missing authorization header' }; } const authorizationHeaderValue = req.rawHeaders[authorizationHeaderIndex + 1]; const token = authorizationHeaderValue && (authorizationHeaderValue.startsWith('Bearer ') ? authorizationHeaderValue.split(' ')[1] : authorizationHeaderValue); if (!token) { return undefined; } const base64Url = token.split('.')[1]; if (!base64Url) { return undefined; } const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const jsonPayload = decodeURIComponent(Buffer.from(base64, 'base64') .toString('ascii') .split('') .map(function (c) { return `%${`00${c.charCodeAt(0).toString(16)}`.slice(-2)}`; }) .join('')); return JSON.parse(jsonPayload); } catch { // TODO } return undefined; } async requestHandler(req, res) { const ctx = RequestContext.get(req); const requestUuid = ctx?.uuid || uuidv4(); const timestamp = Date.now(); const originalHostname = req.headers.host || req.hostname; const unproxiedHostname = req.hostname.replace(this.MOCK_DOMAIN_REGEX, ''); const isProxiedHostname = originalHostname !== unproxiedHostname; const originalRequest = { id: requestUuid, timestamp, url: `${req.protocol}://${req.hostname}${req.originalUrl}`, method: req.method.toUpperCase(), rawHeaders: new RawHeaders(...req.rawHeaders), ...((Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }) || (typeof req.body === 'string' && { body: req.body })), }; logger.debug(originalRequest, 'processing request'); const logContext = { requestId: originalRequest.id, }; const mockEntry = { originalRequest, modifiedRequest: { ...this.unproxyRequest(req), id: requestUuid, timestamp, ...(originalRequest.body && { gqlBody: naiveGQLParser(originalRequest.body) }), jwt: this.extractJwt(originalRequest), }, }; if (!isProxiedHostname) { this.removeProxyPort(mockEntry.modifiedRequest); } const matchingRule = await this.ruleExecutor.findMatchingRule(mockEntry.modifiedRequest); if (matchingRule) { mockEntry.mockRuleId = matchingRule.id; mockEntry.labels = matchingRule.labels; if (matchingRule.actions.mockResponse) { const staticResponse = typeof matchingRule.actions.mockResponse === 'function' ? await matchingRule.actions.mockResponse(mockEntry.modifiedRequest) : matchingRule.actions.mockResponse; mockEntry.modifiedResponse = staticResponse; logger.debug({ ...logContext, staticResponse }, 'replying with mocked response'); if (matchingRule.storeTraffic) { this.trafficStore.set(requestUuid, mockEntry); } if (staticResponse.rawHeaders) { for (const header of staticResponse.rawHeaders.toHeaderPairs()) { res.setHeader(header[0], header[1]); } } res.status(staticResponse.status || 200); res.send(staticResponse.body); // static response blocks any further processing return; } if (matchingRule.actions.modifyRequest) { mockEntry.modifiedRequest = await matchingRule.actions.modifyRequest(mockEntry.modifiedRequest); logger.debug({ ...logContext, modifiedRequest: mockEntry.modifiedRequest }, 'modified original request'); } } if (this.ipUtils && !isProxiedHostname && !this.ipUtils.isIP(originalHostname)) { const hostname = originalHostname.split(':')[0]; try { const internalIPs = await this.ipUtils.resolveIP(hostname); if (this.ipUtils.isLocalhostIP(internalIPs) && this.options.mock.externalDns.length) { const externalIPs = await this.ipUtils.resolveIP(hostname, { useExternalDns: true }); logger.debug({ ...logContext, hostname, externalIPs, internalIPs }, 'switched to external IP'); mockEntry.modifiedRequest.url = mockEntry.modifiedRequest.url.replace(/^(https?:\/\/)[^:/]+/i, `$1${externalIPs}`); } } catch (error) { // swallow the exeception, don't think much can be done at this point logger.warn({ ...logContext, error: errorToLog(error) }, `error trying to resolve IP for "${hostname}"`); } } const originalResponse = this.options.mock.disableProxy ? { timestamp: Date.now(), body: undefined, rawHeaders: new RawHeaders(), status: 404, } : await this.proxyRequest(req, mockEntry, logContext); logger.debug({ ...logContext, originalResponse }, 'received response'); mockEntry.originalResponse = originalResponse; let modifedResponse = { ...originalResponse, rawHeaders: new RawHeaders(...Array.from(originalResponse.rawHeaders.toHeaderPairs()).flatMap(([key, value]) => { // TODO this replace may be too aggressive and doesn't handle protocol (won't be necessary with a trusted cert and mock serving http+https) return [ key, isProxiedHostname ? value : value.replace(new RegExp(`(?:^|\\b)(${escapeStringRegexp(unproxiedHostname)})(?:\\b|$)`, 'igm'), originalHostname), ]; })), }; if (matchingRule?.actions.modifyResponse) { modifedResponse = await matchingRule?.actions.modifyResponse(mockEntry.modifiedRequest, originalResponse); logger.debug({ ...logContext, modifedResponse }, 'modified response'); } mockEntry.modifiedResponse = modifedResponse; if (matchingRule?.storeTraffic) { this.trafficStore.set(requestUuid, mockEntry); } if (modifedResponse.status) { res.status(modifedResponse.status); } if (modifedResponse.rawHeaders) { for (const header of modifedResponse.rawHeaders.toHeaderPairs()) { // since fetch decompresses responses we need to get rid of some headers // TODO maybe could be handled better than just skipping, although express should add these back for new body // if (/^content-(?:length|encoding)$/i.test(header[0])) { // continue; // } res.setHeader(header[0], isProxiedHostname ? header[1].replace(unproxiedHostname, originalHostname) : header[1]); } } res.end(Buffer.from(modifedResponse.body, 'binary')); } bindRequestContext(req, _res, next) { RequestContext.bind(req, this.mockUuid); next(); } errorHandler(error, req, res) { const ctx = RequestContext.get(req); const uuid = ctx?.uuid || uuidv4(); logger.error({ error: errorToLog(error), uuid }, 'unexpected error'); if (res) { res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ error: { message: error.message, httpCode: HttpCode.INTERNAL_SERVER_ERROR, uuid }, }); return; } // eslint-disable-next-line no-console console.error('mock server encountered a critical error. exiting'); process.exit(1); } init() { if (this.mockApp) { return; } this.mockApp = express(); // TODO for now request body is just a buffer passed further, not inflated this.mockApp.use(express.raw({ type: '*/*' })); this.mockApp.use(this.bindRequestContext); this.mockApp.all(/.*/, this.requestHandler); this.mockApp.use(this.errorHandler); } async proxyRequest(req, mockEntry, logContext) { let controller = new AbortController(); const fetchTimeout = setTimeout(() => { if (controller) { controller.abort(`timeout after ${this.options.mock.timeout}`); } }, this.options.mock.timeout); req.on('close', () => { logger.debug(logContext, 'remote client canceled the request'); clearTimeout(fetchTimeout); if (controller) { controller.abort('remote client canceled the request'); } }); let targetResponse; try { const requestOptions = { headers: mockEntry.modifiedRequest.rawHeaders, body: mockEntry.modifiedRequest.body, method: mockEntry.modifiedRequest.method, }; logger.debug({ ...logContext, url: mockEntry.modifiedRequest.url, ...requestOptions, }, 'outgoing request attempt'); // TODO migrate to node-libcurl targetResponse = await fetchRequest(mockEntry.modifiedRequest.url, requestOptions); } catch (error) { logger.error({ ...logContext, error: errorToLog(error), request: mockEntry.modifiedRequest }, 'error fetching'); throw error; } finally { controller = null; clearTimeout(fetchTimeout); } const targetResponseBuffer = Buffer.from(await targetResponse.body.arrayBuffer()); return { timestamp: Date.now(), body: targetResponseBuffer.toString('binary'), status: targetResponse.statusCode, rawHeaders: RawHeaders.fromHeadersRecord(targetResponse.headers), }; } start() { this.init(); if (!this.mockApp) { throw new Error('initialization error'); } if (this.server) { throw new Error('mock server already started'); } if (this.options.mock.httpsPort) { this.serverHttps = https .createServer({ key: this.options.mock.httpsKey, cert: this.options.mock.httpsCert, }, this.mockApp) .listen(this.options.mock.httpsPort, () => { logger.info(`Mock listening on ${this.options.mock.domain}:${this.options.mock.httpsPort}`); }); } this.server = this.mockApp.listen(this.options.mock.port, () => { logger.info(`Mock listening on ${this.options.mock.domain}:${this.options.mock.port}`); if (!this.options.api.disabled) { this.apiServer?.start(); } }); } async stop() { const closePromises = []; if (!this.server) { throw new Error('mock server not started'); } if (!this.options.api.disabled) { if (!this.apiServer) { logger.warn('no api server'); } else { closePromises.push(new Promise((resolve) => { if (!this.apiServer) { resolve(); return; } this.apiServer .stop() .then(() => resolve()) .catch((error) => { logger.error(error, 'problem closing api server'); resolve(); }); })); } } closePromises.push(new Promise((resolve) => { if (!this.server) { resolve(); return; } this.server.close((error) => { if (error) { logger.warn(error, 'problem closing http server'); } this.server = null; resolve(); }); })); if (this.serverHttps) { closePromises.push(new Promise((resolve) => { if (!this.serverHttps) { resolve(); return; } this.serverHttps.close((error) => { if (error) { logger.warn(error, 'problem closing https server'); } this.server = null; resolve(); }); })); } await Promise.all(closePromises); } unproxyRequest(req) { const protocol = (this.MOCK_DOMAIN_REGEX.exec(req.hostname) || [])[2] || req.protocol; const port = (this.MOCK_DOMAIN_REGEX.exec(req.hostname) || [])[1] || undefined; if (!HTTP_METHODS.includes(req.method.toUpperCase())) { throw new Error(`unrecognized http method "${req.method}"`); } // TODO unproxied req might fail if there's a signed url :shrug: // but then we can probably switch DNS for some particular 3rd party server to point to mock // and in mock have a mapping rule for that domain to point directly to some IP :thinking: return { url: `${protocol}://${req.hostname.replace(this.MOCK_DOMAIN_REGEX, '')}${port ? `:${port}` : ''}${req.originalUrl}`, rawHeaders: new RawHeaders(...req.rawHeaders.map((h) => { let outputHeader = h; if (this.MOCK_DOMAIN_REGEX.test(outputHeader)) { outputHeader = outputHeader.replace(this.MOCK_DOMAIN_REGEX, '').replace(/^https?/i, protocol); } return outputHeader; })), method: req.method.toUpperCase(), ...(Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }), }; } removeProxyPort(req) { if (this.URL_PORT_REGEX.test(req.url)) { req.url = req.url.replace(this.URL_PORT_REGEX, '$1$2'); } const host = req.rawHeaders.get('host') || ''; if (host.endsWith(`:${this.options.mock.port}`) || (this.options.mock.httpsPort && host.endsWith(`:${this.options.mock.httpsPort}`))) { req.rawHeaders.set('host', host.split(':')[0]); } } } //# sourceMappingURL=mock.js.map