@stuntman/server 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/api.js +4 -1
- package/dist/mock.d.ts +1 -1
- package/dist/mock.js +115 -115
- package/package.json +3 -2
- package/src/api/api.ts +231 -0
- package/src/api/utils.ts +69 -0
- package/src/api/validators.ts +123 -0
- package/src/api/webgui/rules.pug +145 -0
- package/src/api/webgui/style.css +28 -0
- package/src/api/webgui/traffic.pug +37 -0
- package/src/bin/stuntman.ts +8 -0
- package/src/index.ts +1 -0
- package/src/ipUtils.ts +83 -0
- package/src/mock.ts +375 -0
- package/src/requestContext.ts +23 -0
- package/src/ruleExecutor.ts +211 -0
- package/src/rules/catchAll.ts +14 -0
- package/src/rules/echo.ts +14 -0
- package/src/rules/index.ts +44 -0
- package/src/storage.ts +39 -0
package/dist/api/api.js
CHANGED
|
@@ -26,6 +26,9 @@ class API {
|
|
|
26
26
|
this.apiApp.use(express_1.default.json());
|
|
27
27
|
this.apiApp.use(express_1.default.text());
|
|
28
28
|
this.auth = (req, type) => {
|
|
29
|
+
if (!this.options.apiKeyReadOnly && !this.options.apiKeyReadWrite) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
29
32
|
const hasValidReadKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadOnly;
|
|
30
33
|
const hasValidWriteKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadWrite;
|
|
31
34
|
const hasValidKey = type === 'read' ? hasValidReadKey || hasValidWriteKey : hasValidWriteKey;
|
|
@@ -104,7 +107,7 @@ class API {
|
|
|
104
107
|
});
|
|
105
108
|
return;
|
|
106
109
|
}
|
|
107
|
-
shared_1.logger.error({
|
|
110
|
+
shared_1.logger.error({ message: error.message, stack: error.stack, name: error.name, uuid }, 'unexpected error');
|
|
108
111
|
if (res) {
|
|
109
112
|
res.status(shared_1.HttpCode.INTERNAL_SERVER_ERROR).json({
|
|
110
113
|
error: { message: error.message, httpCode: shared_1.HttpCode.INTERNAL_SERVER_ERROR, uuid },
|
package/dist/mock.d.ts
CHANGED
|
@@ -18,11 +18,11 @@ export declare class Mock {
|
|
|
18
18
|
protected trafficStore: LRUCache<string, Stuntman.LogEntry>;
|
|
19
19
|
protected ipUtils: IPUtils | null;
|
|
20
20
|
private _api;
|
|
21
|
+
private requestHandler;
|
|
21
22
|
get apiServer(): API | null;
|
|
22
23
|
get ruleExecutor(): Stuntman.RuleExecutorInterface;
|
|
23
24
|
constructor(options: Stuntman.ServerConfig);
|
|
24
25
|
private proxyRequest;
|
|
25
|
-
private requestHandler;
|
|
26
26
|
start(): void;
|
|
27
27
|
stop(): void;
|
|
28
28
|
protected unproxyRequest(req: express.Request): Stuntman.BaseRequest;
|
package/dist/mock.js
CHANGED
|
@@ -78,6 +78,120 @@ class Mock {
|
|
|
78
78
|
!this.options.mock.externalDns || this.options.mock.externalDns.length === 0
|
|
79
79
|
? null
|
|
80
80
|
: new ipUtils_1.IPUtils({ mockUuid: this.mockUuid, externalDns: this.options.mock.externalDns });
|
|
81
|
+
this.requestHandler = async (req, res) => {
|
|
82
|
+
var _a, _b, _c, _d, _e;
|
|
83
|
+
const ctx = requestContext_1.default.get(req);
|
|
84
|
+
const requestUuid = (ctx === null || ctx === void 0 ? void 0 : ctx.uuid) || (0, uuid_1.v4)();
|
|
85
|
+
const timestamp = Date.now();
|
|
86
|
+
const originalHostname = req.headers.host || req.hostname;
|
|
87
|
+
const unproxiedHostname = req.hostname.replace(this.MOCK_DOMAIN_REGEX, '');
|
|
88
|
+
const isProxiedHostname = originalHostname !== unproxiedHostname;
|
|
89
|
+
const originalRequest = {
|
|
90
|
+
id: requestUuid,
|
|
91
|
+
timestamp,
|
|
92
|
+
url: `${req.protocol}://${req.hostname}${req.originalUrl}`,
|
|
93
|
+
method: req.method,
|
|
94
|
+
rawHeaders: new shared_1.RawHeaders(...req.rawHeaders),
|
|
95
|
+
...((Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }) ||
|
|
96
|
+
(typeof req.body === 'string' && { body: req.body })),
|
|
97
|
+
};
|
|
98
|
+
shared_1.logger.debug(originalRequest, 'processing request');
|
|
99
|
+
const logContext = {
|
|
100
|
+
requestId: originalRequest.id,
|
|
101
|
+
};
|
|
102
|
+
const mockEntry = {
|
|
103
|
+
originalRequest,
|
|
104
|
+
modifiedRequest: {
|
|
105
|
+
...this.unproxyRequest(req),
|
|
106
|
+
id: requestUuid,
|
|
107
|
+
timestamp,
|
|
108
|
+
...(originalRequest.body && { gqlBody: naiveGQLParser(originalRequest.body) }),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
if (!isProxiedHostname) {
|
|
112
|
+
this.removeProxyPort(mockEntry.modifiedRequest);
|
|
113
|
+
}
|
|
114
|
+
const matchingRule = await (0, ruleExecutor_1.getRuleExecutor)(this.mockUuid).findMatchingRule(mockEntry.modifiedRequest);
|
|
115
|
+
if (matchingRule) {
|
|
116
|
+
mockEntry.mockRuleId = matchingRule.id;
|
|
117
|
+
mockEntry.labels = matchingRule.labels;
|
|
118
|
+
if ((_a = matchingRule.actions) === null || _a === void 0 ? void 0 : _a.mockResponse) {
|
|
119
|
+
const staticResponse = typeof matchingRule.actions.mockResponse === 'function'
|
|
120
|
+
? matchingRule.actions.mockResponse(mockEntry.modifiedRequest)
|
|
121
|
+
: matchingRule.actions.mockResponse;
|
|
122
|
+
mockEntry.modifiedResponse = staticResponse;
|
|
123
|
+
shared_1.logger.debug({ ...logContext, staticResponse }, 'replying with mocked response');
|
|
124
|
+
if (matchingRule.storeTraffic) {
|
|
125
|
+
this.trafficStore.set(requestUuid, mockEntry);
|
|
126
|
+
}
|
|
127
|
+
if (staticResponse.rawHeaders) {
|
|
128
|
+
for (const header of staticResponse.rawHeaders.toHeaderPairs()) {
|
|
129
|
+
res.setHeader(header[0], header[1]);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
res.status(staticResponse.status || 200);
|
|
133
|
+
res.send(staticResponse.body);
|
|
134
|
+
// static response blocks any further processing
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if ((_b = matchingRule.actions) === null || _b === void 0 ? void 0 : _b.modifyRequest) {
|
|
138
|
+
mockEntry.modifiedRequest = (_c = matchingRule.actions) === null || _c === void 0 ? void 0 : _c.modifyRequest(mockEntry.modifiedRequest);
|
|
139
|
+
shared_1.logger.debug({ ...logContext, modifiedRequest: mockEntry.modifiedRequest }, 'modified original request');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (this.ipUtils && !isProxiedHostname && !this.ipUtils.isIP(originalHostname)) {
|
|
143
|
+
const hostname = originalHostname.split(':')[0];
|
|
144
|
+
try {
|
|
145
|
+
const internalIPs = await this.ipUtils.resolveIP(hostname);
|
|
146
|
+
if (this.ipUtils.isLocalhostIP(internalIPs) && this.options.mock.externalDns.length) {
|
|
147
|
+
const externalIPs = await this.ipUtils.resolveIP(hostname, { useExternalDns: true });
|
|
148
|
+
shared_1.logger.debug({ ...logContext, hostname, externalIPs, internalIPs }, 'switched to external IP');
|
|
149
|
+
mockEntry.modifiedRequest.url = mockEntry.modifiedRequest.url.replace(/^(https?:\/\/)[^:/]+/i, `$1${externalIPs}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
// swallow the exeception, don't think much can be done at this point
|
|
154
|
+
shared_1.logger.warn({ ...logContext, error }, `error trying to resolve IP for "${hostname}"`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const originalResponse = await this.proxyRequest(req, mockEntry, logContext);
|
|
158
|
+
shared_1.logger.debug({ ...logContext, originalResponse }, 'received response');
|
|
159
|
+
mockEntry.originalResponse = originalResponse;
|
|
160
|
+
let modifedResponse = {
|
|
161
|
+
...originalResponse,
|
|
162
|
+
rawHeaders: new shared_1.RawHeaders(...Array.from(originalResponse.rawHeaders.toHeaderPairs()).flatMap(([key, value]) => {
|
|
163
|
+
// 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)
|
|
164
|
+
return [
|
|
165
|
+
key,
|
|
166
|
+
isProxiedHostname
|
|
167
|
+
? value
|
|
168
|
+
: value.replace(new RegExp(`(?:^|\\b)(${unproxiedHostname.replace('.', '\\.')})(?:\\b|$)`, 'igm'), originalHostname),
|
|
169
|
+
];
|
|
170
|
+
})),
|
|
171
|
+
};
|
|
172
|
+
if ((_d = matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.actions) === null || _d === void 0 ? void 0 : _d.modifyResponse) {
|
|
173
|
+
modifedResponse = (_e = matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.actions) === null || _e === void 0 ? void 0 : _e.modifyResponse(mockEntry.modifiedRequest, originalResponse);
|
|
174
|
+
shared_1.logger.debug({ ...logContext, modifedResponse }, 'modified response');
|
|
175
|
+
}
|
|
176
|
+
mockEntry.modifiedResponse = modifedResponse;
|
|
177
|
+
if (matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.storeTraffic) {
|
|
178
|
+
this.trafficStore.set(requestUuid, mockEntry);
|
|
179
|
+
}
|
|
180
|
+
if (modifedResponse.status) {
|
|
181
|
+
res.status(modifedResponse.status);
|
|
182
|
+
}
|
|
183
|
+
if (modifedResponse.rawHeaders) {
|
|
184
|
+
for (const header of modifedResponse.rawHeaders.toHeaderPairs()) {
|
|
185
|
+
// since fetch decompresses responses we need to get rid of some headers
|
|
186
|
+
// TODO maybe could be handled better than just skipping, although express should add these back for new body
|
|
187
|
+
// if (/^content-(?:length|encoding)$/i.test(header[0])) {
|
|
188
|
+
// continue;
|
|
189
|
+
// }
|
|
190
|
+
res.setHeader(header[0], isProxiedHostname ? header[1].replace(unproxiedHostname, originalHostname) : header[1]);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
res.end(Buffer.from(modifedResponse.body, 'binary'));
|
|
194
|
+
};
|
|
81
195
|
this.mockApp = (0, express_1.default)();
|
|
82
196
|
// TODO for now request body is just a buffer passed further, not inflated
|
|
83
197
|
this.mockApp.use(express_1.default.raw({ type: '*/*' }));
|
|
@@ -89,7 +203,7 @@ class Mock {
|
|
|
89
203
|
this.mockApp.use((error, req, res, _next) => {
|
|
90
204
|
const ctx = requestContext_1.default.get(req);
|
|
91
205
|
const uuid = (ctx === null || ctx === void 0 ? void 0 : ctx.uuid) || (0, uuid_1.v4)();
|
|
92
|
-
shared_1.logger.error({
|
|
206
|
+
shared_1.logger.error({ message: error.message, stack: error.stack, name: error.name, uuid }, 'unexpected error');
|
|
93
207
|
if (res) {
|
|
94
208
|
res.status(shared_1.HttpCode.INTERNAL_SERVER_ERROR).json({
|
|
95
209
|
error: { message: error.message, httpCode: shared_1.HttpCode.INTERNAL_SERVER_ERROR, uuid },
|
|
@@ -145,120 +259,6 @@ class Mock {
|
|
|
145
259
|
rawHeaders: shared_1.RawHeaders.fromHeadersRecord(targetResponse.headers),
|
|
146
260
|
};
|
|
147
261
|
}
|
|
148
|
-
async requestHandler(req, res) {
|
|
149
|
-
var _a, _b, _c, _d, _e;
|
|
150
|
-
const ctx = requestContext_1.default.get(req);
|
|
151
|
-
const requestUuid = (ctx === null || ctx === void 0 ? void 0 : ctx.uuid) || (0, uuid_1.v4)();
|
|
152
|
-
const timestamp = Date.now();
|
|
153
|
-
const originalHostname = req.headers.host || req.hostname;
|
|
154
|
-
const unproxiedHostname = req.hostname.replace(this.MOCK_DOMAIN_REGEX, '');
|
|
155
|
-
const isProxiedHostname = originalHostname !== unproxiedHostname;
|
|
156
|
-
const originalRequest = {
|
|
157
|
-
id: requestUuid,
|
|
158
|
-
timestamp,
|
|
159
|
-
url: `${req.protocol}://${req.hostname}${req.originalUrl}`,
|
|
160
|
-
method: req.method,
|
|
161
|
-
rawHeaders: new shared_1.RawHeaders(...req.rawHeaders),
|
|
162
|
-
...((Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }) ||
|
|
163
|
-
(typeof req.body === 'string' && { body: req.body })),
|
|
164
|
-
};
|
|
165
|
-
shared_1.logger.debug(originalRequest, 'processing request');
|
|
166
|
-
const logContext = {
|
|
167
|
-
requestId: originalRequest.id,
|
|
168
|
-
};
|
|
169
|
-
const mockEntry = {
|
|
170
|
-
originalRequest,
|
|
171
|
-
modifiedRequest: {
|
|
172
|
-
...this.unproxyRequest(req),
|
|
173
|
-
id: requestUuid,
|
|
174
|
-
timestamp,
|
|
175
|
-
...(originalRequest.body && { gqlBody: naiveGQLParser(originalRequest.body) }),
|
|
176
|
-
},
|
|
177
|
-
};
|
|
178
|
-
if (!isProxiedHostname) {
|
|
179
|
-
this.removeProxyPort(mockEntry.modifiedRequest);
|
|
180
|
-
}
|
|
181
|
-
const matchingRule = await (0, ruleExecutor_1.getRuleExecutor)(this.mockUuid).findMatchingRule(mockEntry.modifiedRequest);
|
|
182
|
-
if (matchingRule) {
|
|
183
|
-
mockEntry.mockRuleId = matchingRule.id;
|
|
184
|
-
mockEntry.labels = matchingRule.labels;
|
|
185
|
-
if ((_a = matchingRule.actions) === null || _a === void 0 ? void 0 : _a.mockResponse) {
|
|
186
|
-
const staticResponse = typeof matchingRule.actions.mockResponse === 'function'
|
|
187
|
-
? matchingRule.actions.mockResponse(mockEntry.modifiedRequest)
|
|
188
|
-
: matchingRule.actions.mockResponse;
|
|
189
|
-
mockEntry.modifiedResponse = staticResponse;
|
|
190
|
-
shared_1.logger.debug({ ...logContext, staticResponse }, 'replying with mocked response');
|
|
191
|
-
if (matchingRule.storeTraffic) {
|
|
192
|
-
this.trafficStore.set(requestUuid, mockEntry);
|
|
193
|
-
}
|
|
194
|
-
if (staticResponse.rawHeaders) {
|
|
195
|
-
for (const header of staticResponse.rawHeaders.toHeaderPairs()) {
|
|
196
|
-
res.setHeader(header[0], header[1]);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
res.status(staticResponse.status || 200);
|
|
200
|
-
res.send(staticResponse.body);
|
|
201
|
-
// static response blocks any further processing
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
if ((_b = matchingRule.actions) === null || _b === void 0 ? void 0 : _b.modifyRequest) {
|
|
205
|
-
mockEntry.modifiedRequest = (_c = matchingRule.actions) === null || _c === void 0 ? void 0 : _c.modifyRequest(mockEntry.modifiedRequest);
|
|
206
|
-
shared_1.logger.debug({ ...logContext, modifiedRequest: mockEntry.modifiedRequest }, 'modified original request');
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
if (this.ipUtils && !isProxiedHostname && !this.ipUtils.isIP(originalHostname)) {
|
|
210
|
-
const hostname = originalHostname.split(':')[0];
|
|
211
|
-
try {
|
|
212
|
-
const internalIPs = await this.ipUtils.resolveIP(hostname);
|
|
213
|
-
if (this.ipUtils.isLocalhostIP(internalIPs) && this.options.mock.externalDns.length) {
|
|
214
|
-
const externalIPs = await this.ipUtils.resolveIP(hostname, { useExternalDns: true });
|
|
215
|
-
shared_1.logger.debug({ ...logContext, hostname, externalIPs, internalIPs }, 'switched to external IP');
|
|
216
|
-
mockEntry.modifiedRequest.url = mockEntry.modifiedRequest.url.replace(/^(https?:\/\/)[^:/]+/i, `$1${externalIPs}`);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
catch (error) {
|
|
220
|
-
// swallow the exeception, don't think much can be done at this point
|
|
221
|
-
shared_1.logger.warn({ ...logContext, error }, `error trying to resolve IP for "${hostname}"`);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
const originalResponse = await this.proxyRequest(req, mockEntry, logContext);
|
|
225
|
-
shared_1.logger.debug({ ...logContext, originalResponse }, 'received response');
|
|
226
|
-
mockEntry.originalResponse = originalResponse;
|
|
227
|
-
let modifedResponse = {
|
|
228
|
-
...originalResponse,
|
|
229
|
-
rawHeaders: new shared_1.RawHeaders(...Array.from(originalResponse.rawHeaders.toHeaderPairs()).flatMap(([key, value]) => {
|
|
230
|
-
// 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)
|
|
231
|
-
return [
|
|
232
|
-
key,
|
|
233
|
-
isProxiedHostname
|
|
234
|
-
? value
|
|
235
|
-
: value.replace(new RegExp(`(?:^|\\b)(${unproxiedHostname.replace('.', '\\.')})(?:\\b|$)`, 'igm'), originalHostname),
|
|
236
|
-
];
|
|
237
|
-
})),
|
|
238
|
-
};
|
|
239
|
-
if ((_d = matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.actions) === null || _d === void 0 ? void 0 : _d.modifyResponse) {
|
|
240
|
-
modifedResponse = (_e = matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.actions) === null || _e === void 0 ? void 0 : _e.modifyResponse(mockEntry.modifiedRequest, originalResponse);
|
|
241
|
-
shared_1.logger.debug({ ...logContext, modifedResponse }, 'modified response');
|
|
242
|
-
}
|
|
243
|
-
mockEntry.modifiedResponse = modifedResponse;
|
|
244
|
-
if (matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.storeTraffic) {
|
|
245
|
-
this.trafficStore.set(requestUuid, mockEntry);
|
|
246
|
-
}
|
|
247
|
-
if (modifedResponse.status) {
|
|
248
|
-
res.status(modifedResponse.status);
|
|
249
|
-
}
|
|
250
|
-
if (modifedResponse.rawHeaders) {
|
|
251
|
-
for (const header of modifedResponse.rawHeaders.toHeaderPairs()) {
|
|
252
|
-
// since fetch decompresses responses we need to get rid of some headers
|
|
253
|
-
// TODO maybe could be handled better than just skipping, although express should add these back for new body
|
|
254
|
-
// if (/^content-(?:length|encoding)$/i.test(header[0])) {
|
|
255
|
-
// continue;
|
|
256
|
-
// }
|
|
257
|
-
res.setHeader(header[0], isProxiedHostname ? header[1].replace(unproxiedHostname, originalHostname) : header[1]);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
res.end(Buffer.from(modifedResponse.body, 'binary'));
|
|
261
|
-
}
|
|
262
262
|
start() {
|
|
263
263
|
if (this.server) {
|
|
264
264
|
throw new Error('mock server already started');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stuntman/server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Stuntman - HTTP proxy / mock server with API",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"repository": {
|
|
@@ -57,7 +57,8 @@
|
|
|
57
57
|
"stuntman": "./dist/bin/stuntman.js"
|
|
58
58
|
},
|
|
59
59
|
"files": [
|
|
60
|
-
"
|
|
60
|
+
"src/**",
|
|
61
|
+
"dist/**",
|
|
61
62
|
"README.md",
|
|
62
63
|
"LICENSE",
|
|
63
64
|
"CHANGELOG.md"
|
package/src/api/api.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import express, { NextFunction, Request, Response, Express as ExpressServer } from 'express';
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
4
|
+
import { getTrafficStore } from '../storage';
|
|
5
|
+
import { getRuleExecutor } from '../ruleExecutor';
|
|
6
|
+
import { logger, AppError, HttpCode, MAX_RULE_TTL_SECONDS, stringify, INDEX_DTS } from '@stuntman/shared';
|
|
7
|
+
import type * as Stuntman from '@stuntman/shared';
|
|
8
|
+
import RequestContext from '../requestContext';
|
|
9
|
+
import serializeJavascript from 'serialize-javascript';
|
|
10
|
+
import LRUCache from 'lru-cache';
|
|
11
|
+
import { validateDeserializedRule } from './validators';
|
|
12
|
+
import { deserializeRule, escapedSerialize, liveRuleToRule } from './utils';
|
|
13
|
+
|
|
14
|
+
type ApiOptions = Stuntman.ApiConfig & {
|
|
15
|
+
mockUuid: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const API_KEY_HEADER = 'x-api-key';
|
|
19
|
+
|
|
20
|
+
export class API {
|
|
21
|
+
protected options: Required<ApiOptions>;
|
|
22
|
+
protected apiApp: ExpressServer;
|
|
23
|
+
trafficStore: LRUCache<string, Stuntman.LogEntry>;
|
|
24
|
+
server: http.Server | null = null;
|
|
25
|
+
auth: (req: Request, type: 'read' | 'write') => void;
|
|
26
|
+
authReadOnly: (req: Request, res: Response, next: NextFunction) => void;
|
|
27
|
+
authReadWrite: (req: Request, res: Response, next: NextFunction) => void;
|
|
28
|
+
|
|
29
|
+
constructor(options: ApiOptions, webGuiOptions?: Stuntman.WebGuiConfig) {
|
|
30
|
+
if (!options.apiKeyReadOnly !== !options.apiKeyReadWrite) {
|
|
31
|
+
throw new Error('apiKeyReadOnly and apiKeyReadWrite options need to be set either both or none');
|
|
32
|
+
}
|
|
33
|
+
this.options = options;
|
|
34
|
+
|
|
35
|
+
this.trafficStore = getTrafficStore(this.options.mockUuid);
|
|
36
|
+
this.apiApp = express();
|
|
37
|
+
|
|
38
|
+
this.apiApp.use(express.json());
|
|
39
|
+
this.apiApp.use(express.text());
|
|
40
|
+
|
|
41
|
+
this.auth = (req: Request, type: 'read' | 'write'): void => {
|
|
42
|
+
if (!this.options.apiKeyReadOnly && !this.options.apiKeyReadWrite) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const hasValidReadKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadOnly;
|
|
46
|
+
const hasValidWriteKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadWrite;
|
|
47
|
+
const hasValidKey = type === 'read' ? hasValidReadKey || hasValidWriteKey : hasValidWriteKey;
|
|
48
|
+
if (!hasValidKey) {
|
|
49
|
+
throw new AppError({ httpCode: HttpCode.UNAUTHORIZED, message: 'unauthorized' });
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
this.authReadOnly = (req: Request, res: Response, next: NextFunction): void => {
|
|
55
|
+
this.auth(req, 'read');
|
|
56
|
+
next();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
this.authReadWrite = (req: Request, res: Response, next: NextFunction): void => {
|
|
60
|
+
this.auth(req, 'write');
|
|
61
|
+
next();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
this.apiApp.use((req: Request, res: Response, next: NextFunction) => {
|
|
65
|
+
RequestContext.bind(req, this.options.mockUuid);
|
|
66
|
+
next();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
this.apiApp.get('/rule', this.authReadOnly, async (req, res) => {
|
|
70
|
+
res.send(stringify(await getRuleExecutor(this.options.mockUuid).getRules()));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
this.apiApp.get('/rule/:ruleId', this.authReadOnly, async (req, res) => {
|
|
74
|
+
res.send(stringify(await getRuleExecutor(this.options.mockUuid).getRule(req.params.ruleId)));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
this.apiApp.get('/rule/:ruleId/disable', this.authReadWrite, (req, res) => {
|
|
78
|
+
getRuleExecutor(this.options.mockUuid).disableRule(req.params.ruleId);
|
|
79
|
+
res.send();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
this.apiApp.get('/rule/:ruleId/enable', this.authReadWrite, (req, res) => {
|
|
83
|
+
getRuleExecutor(this.options.mockUuid).enableRule(req.params.ruleId);
|
|
84
|
+
res.send();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
this.apiApp.post(
|
|
88
|
+
'/rule',
|
|
89
|
+
this.authReadWrite,
|
|
90
|
+
async (req: Request<object, string, Stuntman.SerializedRule>, res: Response) => {
|
|
91
|
+
const deserializedRule = deserializeRule(req.body);
|
|
92
|
+
validateDeserializedRule(deserializedRule);
|
|
93
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
94
|
+
// @ts-ignore
|
|
95
|
+
const rule = await getRuleExecutor(this.options.mockUuid).addRule(deserializedRule);
|
|
96
|
+
res.send(stringify(rule));
|
|
97
|
+
}
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
this.apiApp.get('/rule/:ruleId/remove', this.authReadWrite, async (req, res) => {
|
|
101
|
+
await getRuleExecutor(this.options.mockUuid).removeRule(req.params.ruleId);
|
|
102
|
+
res.send();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
this.apiApp.get('/traffic', this.authReadOnly, (req, res) => {
|
|
106
|
+
const serializedTraffic: Record<string, Stuntman.LogEntry> = {};
|
|
107
|
+
for (const [key, value] of this.trafficStore.entries()) {
|
|
108
|
+
serializedTraffic[key] = value;
|
|
109
|
+
}
|
|
110
|
+
res.json(serializedTraffic);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
this.apiApp.get('/traffic/:ruleIdOrLabel', this.authReadOnly, (req, res) => {
|
|
114
|
+
const serializedTraffic: Record<string, Stuntman.LogEntry> = {};
|
|
115
|
+
for (const [key, value] of this.trafficStore.entries()) {
|
|
116
|
+
if (value.mockRuleId === req.params.ruleIdOrLabel || (value.labels || []).includes(req.params.ruleIdOrLabel)) {
|
|
117
|
+
serializedTraffic[key] = value;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
res.json(serializedTraffic);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (!webGuiOptions?.disabled) {
|
|
124
|
+
this.apiApp.set('views', __dirname + '/webgui');
|
|
125
|
+
this.apiApp.set('view engine', 'pug');
|
|
126
|
+
this.initWebGui();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.apiApp.all(/.*/, (req: Request, res: Response) => res.status(404).send());
|
|
130
|
+
|
|
131
|
+
this.apiApp.use((error: Error | AppError, req: Request, res: Response) => {
|
|
132
|
+
const ctx: RequestContext | null = RequestContext.get(req);
|
|
133
|
+
const uuid = ctx?.uuid || uuidv4();
|
|
134
|
+
if (error instanceof AppError && error.isOperational && res) {
|
|
135
|
+
logger.error(error);
|
|
136
|
+
res.status(error.httpCode).json({
|
|
137
|
+
error: { message: error.message, httpCode: error.httpCode, stack: error.stack },
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
logger.error({ message: error.message, stack: error.stack, name: error.name, uuid }, 'unexpected error');
|
|
142
|
+
if (res) {
|
|
143
|
+
res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
|
144
|
+
error: { message: error.message, httpCode: HttpCode.INTERNAL_SERVER_ERROR, uuid },
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// eslint-disable-next-line no-console
|
|
149
|
+
console.log('API server encountered a critical error. Exiting');
|
|
150
|
+
process.exit(1);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private initWebGui() {
|
|
155
|
+
this.apiApp.get('/webgui/rules', this.authReadOnly, async (req, res) => {
|
|
156
|
+
const rules: Record<string, string> = {};
|
|
157
|
+
for (const rule of await getRuleExecutor(this.options.mockUuid).getRules()) {
|
|
158
|
+
rules[rule.id] = serializeJavascript(liveRuleToRule(rule), { unsafe: true });
|
|
159
|
+
}
|
|
160
|
+
res.render('rules', { rules: escapedSerialize(rules), INDEX_DTS, ruleKeys: Object.keys(rules) });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
this.apiApp.get('/webgui/traffic', this.authReadOnly, async (req, res) => {
|
|
164
|
+
const serializedTraffic: Stuntman.LogEntry[] = [];
|
|
165
|
+
for (const value of this.trafficStore.values()) {
|
|
166
|
+
serializedTraffic.push(value);
|
|
167
|
+
}
|
|
168
|
+
res.render('traffic', {
|
|
169
|
+
traffic: JSON.stringify(
|
|
170
|
+
serializedTraffic.sort((a, b) => b.originalRequest.timestamp - a.originalRequest.timestamp)
|
|
171
|
+
),
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// TODO make webui way better and safer, nicer formatting, eslint/prettier, blackjack and hookers
|
|
176
|
+
|
|
177
|
+
this.apiApp.post('/webgui/rules/unsafesave', this.authReadWrite, async (req, res) => {
|
|
178
|
+
const rule: Stuntman.Rule = new Function(req.body)();
|
|
179
|
+
if (
|
|
180
|
+
!rule ||
|
|
181
|
+
!rule.id ||
|
|
182
|
+
typeof rule.matches !== 'function' ||
|
|
183
|
+
typeof rule.ttlSeconds !== 'number' ||
|
|
184
|
+
rule.ttlSeconds > MAX_RULE_TTL_SECONDS
|
|
185
|
+
) {
|
|
186
|
+
throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'Invalid rule' });
|
|
187
|
+
}
|
|
188
|
+
await getRuleExecutor(this.options.mockUuid).addRule(
|
|
189
|
+
{
|
|
190
|
+
id: rule.id,
|
|
191
|
+
matches: rule.matches,
|
|
192
|
+
ttlSeconds: rule.ttlSeconds,
|
|
193
|
+
...(rule.actions && {
|
|
194
|
+
actions: {
|
|
195
|
+
...(rule.actions.mockResponse
|
|
196
|
+
? { mockResponse: rule.actions.mockResponse }
|
|
197
|
+
: { modifyRequest: rule.actions.modifyRequest, modifyResponse: rule.actions.modifyResponse }),
|
|
198
|
+
},
|
|
199
|
+
}),
|
|
200
|
+
...(rule.disableAfterUse !== undefined && { disableAfterUse: rule.disableAfterUse }),
|
|
201
|
+
...(rule.isEnabled !== undefined && { isEnabled: rule.isEnabled }),
|
|
202
|
+
...(rule.labels !== undefined && { labels: rule.labels }),
|
|
203
|
+
...(rule.priority !== undefined && { priority: rule.priority }),
|
|
204
|
+
...(rule.removeAfterUse !== undefined && { removeAfterUse: rule.removeAfterUse }),
|
|
205
|
+
...(rule.storeTraffic !== undefined && { storeTraffic: rule.storeTraffic }),
|
|
206
|
+
},
|
|
207
|
+
true
|
|
208
|
+
);
|
|
209
|
+
res.send();
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
public start() {
|
|
214
|
+
if (this.server) {
|
|
215
|
+
throw new Error('mock server already started');
|
|
216
|
+
}
|
|
217
|
+
this.server = this.apiApp.listen(this.options.port, () => {
|
|
218
|
+
logger.info(`API listening on ${this.options.port}`);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
public stop() {
|
|
223
|
+
if (!this.server) {
|
|
224
|
+
throw new Error('mock server not started');
|
|
225
|
+
}
|
|
226
|
+
this.server.close((error) => {
|
|
227
|
+
logger.warn(error, 'problem closing server');
|
|
228
|
+
this.server = null;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
package/src/api/utils.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import serializeJavascript from 'serialize-javascript';
|
|
2
|
+
import type * as Stuntman from '@stuntman/shared';
|
|
3
|
+
import { logger } from '@stuntman/shared';
|
|
4
|
+
import { validateSerializedRuleProperties } from './validators';
|
|
5
|
+
|
|
6
|
+
// TODO
|
|
7
|
+
export const deserializeRule = (serializedRule: Stuntman.SerializedRule): Stuntman.Rule => {
|
|
8
|
+
logger.debug(serializedRule, 'attempt to deserialize rule');
|
|
9
|
+
validateSerializedRuleProperties(serializedRule);
|
|
10
|
+
const rule: Stuntman.Rule = {
|
|
11
|
+
id: serializedRule.id,
|
|
12
|
+
matches: (req: Stuntman.Request) => new Function('____arg0', serializedRule.matches.remoteFn)(req),
|
|
13
|
+
ttlSeconds: serializedRule.ttlSeconds,
|
|
14
|
+
...(serializedRule.disableAfterUse !== undefined && { disableAfterUse: serializedRule.disableAfterUse }),
|
|
15
|
+
...(serializedRule.removeAfterUse !== undefined && { removeAfterUse: serializedRule.removeAfterUse }),
|
|
16
|
+
...(serializedRule.labels !== undefined && { labels: serializedRule.labels }),
|
|
17
|
+
...(serializedRule.priority !== undefined && { priority: serializedRule.priority }),
|
|
18
|
+
...(serializedRule.isEnabled !== undefined && { isEnabled: serializedRule.isEnabled }),
|
|
19
|
+
...(serializedRule.storeTraffic !== undefined && { storeTraffic: serializedRule.storeTraffic }),
|
|
20
|
+
};
|
|
21
|
+
if (serializedRule.actions) {
|
|
22
|
+
// TODO store the original localFn and variables sent from client for web UI editing maybe
|
|
23
|
+
if (serializedRule.actions.mockResponse) {
|
|
24
|
+
rule.actions = {
|
|
25
|
+
mockResponse:
|
|
26
|
+
'remoteFn' in serializedRule.actions.mockResponse
|
|
27
|
+
? (req: Stuntman.Request) =>
|
|
28
|
+
new Function(
|
|
29
|
+
'____arg0',
|
|
30
|
+
(serializedRule.actions?.mockResponse as Stuntman.SerializedRemotableFunction).remoteFn
|
|
31
|
+
)(req)
|
|
32
|
+
: serializedRule.actions.mockResponse,
|
|
33
|
+
};
|
|
34
|
+
} else {
|
|
35
|
+
rule.actions = {};
|
|
36
|
+
if (serializedRule.actions.modifyRequest) {
|
|
37
|
+
rule.actions.modifyRequest = (req: Stuntman.Request) =>
|
|
38
|
+
new Function(
|
|
39
|
+
'____arg0',
|
|
40
|
+
(serializedRule.actions?.modifyRequest as Stuntman.SerializedRemotableFunction).remoteFn
|
|
41
|
+
)(req);
|
|
42
|
+
}
|
|
43
|
+
if (serializedRule.actions.modifyResponse) {
|
|
44
|
+
rule.actions.modifyResponse = (req: Stuntman.Request, res: Stuntman.Response) =>
|
|
45
|
+
new Function(
|
|
46
|
+
'____arg0',
|
|
47
|
+
'____arg1',
|
|
48
|
+
(serializedRule.actions?.modifyResponse as Stuntman.SerializedRemotableFunction).remoteFn
|
|
49
|
+
)(req, res);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
logger.debug(rule, 'deserialized rule');
|
|
54
|
+
return rule;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const escapedSerialize = (obj: any) =>
|
|
58
|
+
serializeJavascript(obj).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, "\\n'\n+ '");
|
|
59
|
+
|
|
60
|
+
export const liveRuleToRule = (liveRule: Stuntman.LiveRule) => {
|
|
61
|
+
const ruleClone: Stuntman.Rule = { ...liveRule };
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
63
|
+
// @ts-ignore
|
|
64
|
+
delete ruleClone.counter;
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
66
|
+
// @ts-ignore
|
|
67
|
+
delete ruleClone.createdTimestamp;
|
|
68
|
+
return ruleClone;
|
|
69
|
+
};
|