@stuntman/server
Version:
Stuntman - HTTP proxy / mock server with API
463 lines (436 loc) • 19.6 kB
text/typescript
import { request as fetchRequest } from 'undici';
import type { Dispatcher } from 'undici';
import http from 'http';
import https from 'https';
import express from 'express';
import { v4 as uuidv4 } from 'uuid';
import { getRuleExecutor } from './ruleExecutor';
import { getTrafficStore } from './storage';
import { RawHeaders, logger, HttpCode, naiveGQLParser, escapeStringRegexp, errorToLog, HTTP_METHODS } from '@stuntman/shared';
import { RequestContext } from './requestContext';
import type * as Stuntman from '@stuntman/shared';
import { IPUtils } from './ipUtils';
import { LRUCache } from 'lru-cache';
import { API } from './api/api';
// TODO add proper web proxy mode
export class Mock {
public readonly mockUuid: string;
protected options: Stuntman.Config;
protected mockApp: express.Express | null = null;
protected MOCK_DOMAIN_REGEX: RegExp;
protected URL_PORT_REGEX: RegExp;
protected server: http.Server | null = null;
protected serverHttps: https.Server | null = null;
protected trafficStore: LRUCache<string, Stuntman.LogEntry>;
protected ipUtils: IPUtils | null = null;
private _api: API | null = null;
protected 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;
}
public get ruleExecutor(): Stuntman.RuleExecutorInterface {
return getRuleExecutor(this.mockUuid);
}
constructor(options: Stuntman.Config) {
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);
}
private extractJwt(req: Stuntman.Request): any {
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;
}
private async requestHandler(req: express.Request, res: express.Response): Promise<void> {
const ctx: RequestContext | null = 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: Stuntman.Request = {
id: requestUuid,
timestamp,
url: `${req.protocol}://${req.hostname}${req.originalUrl}`,
method: req.method.toUpperCase() as Stuntman.HttpMethod,
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: Record<string, any> = {
requestId: originalRequest.id,
};
const mockEntry: Stuntman.WithRequiredProperty<Stuntman.LogEntry, 'modifiedRequest'> = {
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 as Error) }, `error trying to resolve IP for "${hostname}"`);
}
}
const originalResponse: Required<Stuntman.Response> = 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: Stuntman.Response = {
...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'));
}
private bindRequestContext(req: express.Request, _res: express.Response, next: express.NextFunction) {
RequestContext.bind(req, this.mockUuid);
next();
}
private errorHandler(error: Error, req: express.Request, res: express.Response) {
const ctx: RequestContext | null = 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);
}
private 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);
}
private async proxyRequest(
req: express.Request,
mockEntry: Stuntman.WithRequiredProperty<Stuntman.LogEntry, 'modifiedRequest'>,
logContext: any
) {
let controller: AbortController | null = 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: Dispatcher.ResponseData;
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 as 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),
};
}
public 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();
}
});
}
public async stop(): Promise<void> {
const closePromises: Promise<void>[] = [];
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<void>((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<void>((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<void>((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);
}
protected unproxyRequest(req: express.Request): Stuntman.BaseRequest {
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() as Stuntman.HttpMethod)) {
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() as Stuntman.HttpMethod,
...(Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }),
};
}
protected removeProxyPort(req: Stuntman.Request): void {
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]!);
}
}
}