@stuntman/server
Version:
Stuntman - HTTP proxy / mock server with API
393 lines • 17.4 kB
JavaScript
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