@stuntman/server 0.1.0 → 0.1.1
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/README.md +121 -0
- package/package.json +12 -2
- package/config/default.json +0 -23
- package/config/test.json +0 -26
- package/src/api/api.ts +0 -194
- package/src/api/utils.ts +0 -69
- package/src/api/validatiors.ts +0 -123
- package/src/api/webgui/rules.pug +0 -145
- package/src/api/webgui/style.css +0 -28
- package/src/api/webgui/traffic.pug +0 -37
- package/src/bin/stuntman.ts +0 -8
- package/src/index.ts +0 -1
- package/src/ipUtils.ts +0 -83
- package/src/mock.ts +0 -349
- package/src/requestContext.ts +0 -23
- package/src/ruleExecutor.ts +0 -193
- package/src/rules/catchAll.ts +0 -14
- package/src/rules/echo.ts +0 -14
- package/src/rules/index.ts +0 -7
- package/src/storage.ts +0 -39
- package/tsconfig.json +0 -16
package/src/api/webgui/rules.pug
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
doctype html
|
|
2
|
-
html
|
|
3
|
-
head
|
|
4
|
-
title Stuntman - rule editor
|
|
5
|
-
script(src='https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.35.0/min/vs/loader.min.js')
|
|
6
|
-
style
|
|
7
|
-
include style.css
|
|
8
|
-
body(style='color: rgb(204, 204, 204); background-color: rgb(50, 50, 50)')
|
|
9
|
-
button#newRule(type='button', onclick='window.newRule()') New rule
|
|
10
|
-
button#saveRule(type='button', onclick='window.saveRule()', disabled) Save rule
|
|
11
|
-
div(style='width: 100%; overflow: hidden')
|
|
12
|
-
div(style='width: 230px; float: left')
|
|
13
|
-
h3 Rules
|
|
14
|
-
ul#ruleKeys.no-bullets
|
|
15
|
-
each ruleId in ruleKeys
|
|
16
|
-
li
|
|
17
|
-
button.rule(
|
|
18
|
-
type='button',
|
|
19
|
-
onclick='window.setRuleModel(this.getAttribute("data-rule-id"))',
|
|
20
|
-
data-rule-id=ruleId
|
|
21
|
-
)= ruleId
|
|
22
|
-
div(style='margin-left: 240px')
|
|
23
|
-
#container(style='height: 400px')
|
|
24
|
-
script.
|
|
25
|
-
const uuidv4 = () => {
|
|
26
|
-
function getRandomSymbol(symbol) {
|
|
27
|
-
var array;
|
|
28
|
-
|
|
29
|
-
if (symbol === 'y') {
|
|
30
|
-
array = ['8', '9', 'a', 'b'];
|
|
31
|
-
return array[Math.floor(Math.random() * array.length)];
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
array = new Uint8Array(1);
|
|
35
|
-
window.crypto.getRandomValues(array);
|
|
36
|
-
return (array[0] % 16).toString(16);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, getRandomSymbol);
|
|
40
|
-
};
|
|
41
|
-
require.config({
|
|
42
|
-
paths: {
|
|
43
|
-
vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.35.0/min/vs',
|
|
44
|
-
},
|
|
45
|
-
});
|
|
46
|
-
require(['vs/editor/editor.main'], function () {
|
|
47
|
-
monaco.languages.typescript.javascriptDefaults.setEagerModelSync(true);
|
|
48
|
-
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
|
49
|
-
noSemanticValidation: false,
|
|
50
|
-
noSyntaxValidation: false,
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
// compiler options
|
|
54
|
-
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
|
|
55
|
-
target: monaco.languages.typescript.ScriptTarget.ES6,
|
|
56
|
-
allowNonTsExtensions: true,
|
|
57
|
-
noLib: true,
|
|
58
|
-
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
|
|
59
|
-
module: monaco.languages.typescript.ModuleKind.CommonJS,
|
|
60
|
-
noEmit: true,
|
|
61
|
-
checkJs: true,
|
|
62
|
-
allowJs: true,
|
|
63
|
-
isolatedModules: true,
|
|
64
|
-
typeRoots: ['node_modules/@types'],
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
monaco.languages.typescript.typescriptDefaults.addExtraLib(`!{INDEX_DTS}`, 'file:///node_modules/@types/stuntman/index.d.ts');
|
|
68
|
-
const models = {};
|
|
69
|
-
const rules = eval('(!{rules})');
|
|
70
|
-
for (const ruleId of Object.keys(rules)) {
|
|
71
|
-
models[ruleId] = monaco.editor.createModel("import type * as Stuntman from 'stuntman';\n\nvar STUNTMAN_RULE: Stuntman.Rule = " + rules[ruleId] + ';', 'typescript', `file:///${ruleId}.ts`);
|
|
72
|
-
}
|
|
73
|
-
const editor = monaco.editor.create(document.getElementById('container'), {
|
|
74
|
-
theme: 'vs-dark',
|
|
75
|
-
autoIndent: true,
|
|
76
|
-
formatOnPaste: true,
|
|
77
|
-
formatOnType: true,
|
|
78
|
-
automaticLayout: true,
|
|
79
|
-
autoIndent: true,
|
|
80
|
-
tabSize: 2,
|
|
81
|
-
});
|
|
82
|
-
editor.onDidChangeModel((event) => {
|
|
83
|
-
const isInternal = /^file:\/\/\/internal\/.+/.test(event.newModelUrl);
|
|
84
|
-
if (isInternal) {
|
|
85
|
-
document.getElementById('saveRule').setAttribute('disabled', 'true');
|
|
86
|
-
} else {
|
|
87
|
-
document.getElementById('saveRule').removeAttribute('disabled');
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
setTimeout(() => {
|
|
91
|
-
editor.getAction('editor.action.formatDocument').run();
|
|
92
|
-
}, 100);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
window.setRuleModel = (ruleId) => {
|
|
96
|
-
if (history.pushState) {
|
|
97
|
-
const newUrl = `${window.location.protocol}//${window.location.host}${window.location.pathname}?ruleId=${encodeURIComponent(ruleId)}`;
|
|
98
|
-
window.history.pushState({ path: newUrl }, '', newUrl);
|
|
99
|
-
}
|
|
100
|
-
editor.setModel(models[ruleId]);
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
const urlSearchParams = new URLSearchParams(window.location.search);
|
|
104
|
-
if (urlSearchParams.has('ruleId') && urlSearchParams.get('ruleId') in models) {
|
|
105
|
-
editor.setModel(models[urlSearchParams.get('ruleId')]);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
window.saveRule = () => {
|
|
109
|
-
document.getElementById('saveRule').setAttribute('disabled', 'true');
|
|
110
|
-
const modelUri = editor.getModel().uri;
|
|
111
|
-
const result = monaco.languages.typescript.getTypeScriptWorker();
|
|
112
|
-
result.then((worker) => {
|
|
113
|
-
worker(modelUri).then(function (client) {
|
|
114
|
-
client.getEmitOutput(modelUri.toString()).then((output) => {
|
|
115
|
-
const ruleFunctionText = output.outputFiles[0].text.replace(/^export .+$/im, '');
|
|
116
|
-
const newId = new Function(ruleFunctionText + '\n return STUNTMAN_RULE;')().id;
|
|
117
|
-
fetch('/webgui/rules/unsafesave', {
|
|
118
|
-
method: 'POST',
|
|
119
|
-
headers: { 'content-type': 'text/plain' },
|
|
120
|
-
body: ruleFunctionText + '\n return STUNTMAN_RULE;',
|
|
121
|
-
}).then((response) => {
|
|
122
|
-
if (response.ok) {
|
|
123
|
-
window.location.reload();
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
alert('Error when saving rule');
|
|
127
|
-
document.getElementById('saveRule').removeAttribute('disabled');
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
window.newRule = () => {
|
|
135
|
-
const ruleId = uuidv4();
|
|
136
|
-
const emptyRule = `import type * as Stuntman from \'stuntman\';\n\nvar STUNTMAN_RULE: Stuntman.Rule = { id: '${ruleId}', matches: (req: Stuntman.Request) => true, ttlSeconds: 600 };`;
|
|
137
|
-
models[ruleId] = monaco.editor.createModel(emptyRule, 'typescript', `file:///${ruleId}.ts`);
|
|
138
|
-
const ruleKeyNode = document.getElementById('ruleKeys').firstChild;
|
|
139
|
-
const ruleKeyNodeClone = ruleKeyNode.cloneNode(true);
|
|
140
|
-
ruleKeyNodeClone.getElementsByTagName('button')[0].setAttribute('data-rule-id', ruleId);
|
|
141
|
-
ruleKeyNodeClone.getElementsByTagName('button')[0].innerText = ruleId;
|
|
142
|
-
document.getElementById('ruleKeys').appendChild(ruleKeyNodeClone);
|
|
143
|
-
window.setRuleModel(ruleId);
|
|
144
|
-
};
|
|
145
|
-
});
|
package/src/api/webgui/style.css
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
div#container {
|
|
2
|
-
resize: vertical;
|
|
3
|
-
overflow: auto;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
body {
|
|
7
|
-
font-family: Menlo, Monaco, 'Courier New', monospace;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
button.rule {
|
|
11
|
-
font-family: Menlo, Monaco, 'Courier New', monospace;
|
|
12
|
-
background: none !important;
|
|
13
|
-
border: none;
|
|
14
|
-
padding: 0 !important;
|
|
15
|
-
color: #aaa;
|
|
16
|
-
text-decoration: underline;
|
|
17
|
-
cursor: pointer;
|
|
18
|
-
margin-top: 10px;
|
|
19
|
-
font-size: x-small;
|
|
20
|
-
text-align: left;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
ul.no-bullets {
|
|
24
|
-
list-style-type: none; /* Remove bullets */
|
|
25
|
-
padding: 0; /* Remove padding */
|
|
26
|
-
margin: 0; /* Remove margins */
|
|
27
|
-
text-align: left;
|
|
28
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
doctype html
|
|
2
|
-
html
|
|
3
|
-
head
|
|
4
|
-
title Stuntman - rule editor
|
|
5
|
-
script(src='https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.35.0/min/vs/loader.min.js')
|
|
6
|
-
style
|
|
7
|
-
include style.css
|
|
8
|
-
body(style='color: rgb(204, 204, 204); background-color: rgb(50, 50, 50)')
|
|
9
|
-
div(style='width: 100%; overflow: hidden')
|
|
10
|
-
div(style='width: 200px; float: left')
|
|
11
|
-
h3 Traffic log
|
|
12
|
-
div(style='margin-left: 220px')
|
|
13
|
-
#container(style='height: 800px')
|
|
14
|
-
script.
|
|
15
|
-
require.config({
|
|
16
|
-
paths: {
|
|
17
|
-
vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.35.0/min/vs',
|
|
18
|
-
},
|
|
19
|
-
});
|
|
20
|
-
require(['vs/editor/editor.main'], function () {
|
|
21
|
-
const traffic = !{ traffic };
|
|
22
|
-
const model = monaco.editor.createModel(JSON.stringify(traffic, null, 2), 'json');
|
|
23
|
-
const editor = monaco.editor.create(document.getElementById('container'), {
|
|
24
|
-
theme: 'vs-dark',
|
|
25
|
-
autoIndent: true,
|
|
26
|
-
formatOnPaste: true,
|
|
27
|
-
formatOnType: true,
|
|
28
|
-
automaticLayout: true,
|
|
29
|
-
readOnly: true,
|
|
30
|
-
});
|
|
31
|
-
editor.onDidChangeModel((event) => {
|
|
32
|
-
setTimeout(() => {
|
|
33
|
-
editor.getAction('editor.action.formatDocument').run();
|
|
34
|
-
}, 100);
|
|
35
|
-
});
|
|
36
|
-
editor.setModel(model);
|
|
37
|
-
});
|
package/src/bin/stuntman.ts
DELETED
package/src/index.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { Mock as StuntmanMock } from './mock';
|
package/src/ipUtils.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import { networkInterfaces } from 'os';
|
|
2
|
-
import dns, { Resolver as DNSResolver } from 'node:dns';
|
|
3
|
-
import { getDnsResolutionCache } from './storage';
|
|
4
|
-
import { logger } from '@stuntman/shared';
|
|
5
|
-
|
|
6
|
-
const localhostIPs: string[] = ['127.0.0.1'];
|
|
7
|
-
const IP_WITH_OPTIONAL_PORT_REGEX = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}(:[0-9]+)?$/i;
|
|
8
|
-
|
|
9
|
-
for (const nets of Object.values(networkInterfaces())) {
|
|
10
|
-
if (!nets) {
|
|
11
|
-
continue;
|
|
12
|
-
}
|
|
13
|
-
for (const net of nets) {
|
|
14
|
-
const familyV4Value = typeof net.family === 'string' ? 'IPv4' : 4;
|
|
15
|
-
if (net.family === familyV4Value && !localhostIPs.includes(net.address)) {
|
|
16
|
-
localhostIPs.push(net.address);
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export class IPUtils {
|
|
22
|
-
protected dnsResolutionCache;
|
|
23
|
-
protected mockUuid: string;
|
|
24
|
-
externalDns: dns.Resolver | null = null;
|
|
25
|
-
|
|
26
|
-
constructor(options: { mockUuid: string; externalDns?: string[] }) {
|
|
27
|
-
this.mockUuid = options.mockUuid;
|
|
28
|
-
if (options.externalDns?.length) {
|
|
29
|
-
this.externalDns = new DNSResolver();
|
|
30
|
-
this.externalDns.setServers(options.externalDns);
|
|
31
|
-
}
|
|
32
|
-
this.dnsResolutionCache = getDnsResolutionCache(this.mockUuid);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
isLocalhostIP(ip: string): boolean {
|
|
36
|
-
return localhostIPs.includes(ip);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
private async resolveIPs(hostname: string, options?: { useExternalDns?: true }): Promise<[string, ...string[]]> {
|
|
40
|
-
return new Promise<[string, ...string[]]>((resolve, reject) => {
|
|
41
|
-
const callback = (error: NodeJS.ErrnoException | null, addresses: string | string[]) => {
|
|
42
|
-
if (error) {
|
|
43
|
-
logger.debug({ error, hostname }, 'error resolving hostname');
|
|
44
|
-
reject(error);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
if (typeof addresses === 'string') {
|
|
48
|
-
logger.debug({ ip: [addresses], hostname }, 'resolved hostname');
|
|
49
|
-
resolve([addresses]);
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
if (!addresses || addresses.length === 0) {
|
|
53
|
-
logger.debug({ hostname }, 'missing IPs');
|
|
54
|
-
reject(new Error('No addresses found'));
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
logger.debug({ ip: addresses, hostname }, 'resolved hostname');
|
|
58
|
-
resolve([addresses[0], ...addresses.slice(1)]);
|
|
59
|
-
};
|
|
60
|
-
if (options?.useExternalDns) {
|
|
61
|
-
if (!this.externalDns) {
|
|
62
|
-
reject(new Error('external dns servers not set'));
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
this.externalDns.resolve(hostname, callback);
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
dns.lookup(hostname, callback);
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async resolveIP(hostname: string, options?: { useExternalDns?: true }): Promise<string> {
|
|
73
|
-
const cachedIP = this.dnsResolutionCache.get(hostname);
|
|
74
|
-
if (cachedIP) {
|
|
75
|
-
return cachedIP;
|
|
76
|
-
}
|
|
77
|
-
return (await this.resolveIPs(hostname, options))[0];
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
isIP(hostname: string): boolean {
|
|
81
|
-
return IP_WITH_OPTIONAL_PORT_REGEX.test(hostname);
|
|
82
|
-
}
|
|
83
|
-
}
|
package/src/mock.ts
DELETED
|
@@ -1,349 +0,0 @@
|
|
|
1
|
-
import http from 'http';
|
|
2
|
-
import https from 'https';
|
|
3
|
-
import express from 'express';
|
|
4
|
-
import { v4 as uuidv4 } from 'uuid';
|
|
5
|
-
import { ruleExecutor } from './ruleExecutor';
|
|
6
|
-
import { getTrafficStore } from './storage';
|
|
7
|
-
import { RawHeaders, logger, HttpCode } from '@stuntman/shared';
|
|
8
|
-
import RequestContext from './requestContext';
|
|
9
|
-
import type * as Stuntman from '@stuntman/shared';
|
|
10
|
-
import { IPUtils } from './ipUtils';
|
|
11
|
-
import LRUCache from 'lru-cache';
|
|
12
|
-
import { API } from './api/api';
|
|
13
|
-
|
|
14
|
-
type WithRequiredProperty<Type, Key extends keyof Type> = Type & {
|
|
15
|
-
[Property in Key]-?: Type[Property];
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
const naiveGQLParser = (body: Buffer | string): Stuntman.GQLRequestBody | undefined => {
|
|
19
|
-
try {
|
|
20
|
-
let json: Stuntman.GQLRequestBody | undefined = undefined;
|
|
21
|
-
try {
|
|
22
|
-
json = JSON.parse(Buffer.isBuffer(body) ? body.toString('utf-8') : body);
|
|
23
|
-
} catch (kiss) {
|
|
24
|
-
// and swallow
|
|
25
|
-
}
|
|
26
|
-
if (!json?.query && !json?.operationName) {
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
const lines = json.query
|
|
30
|
-
.split('\n')
|
|
31
|
-
.map((l) => l.replace(/^\s+/g, '').trim())
|
|
32
|
-
.filter((l) => !!l);
|
|
33
|
-
if (/^query /.test(lines[0])) {
|
|
34
|
-
json.type = 'query';
|
|
35
|
-
} else if (/^mutation /.test(lines[0])) {
|
|
36
|
-
json.type = 'mutation';
|
|
37
|
-
} else {
|
|
38
|
-
throw new Error(`Unable to resolve query type of ${lines[0]}`);
|
|
39
|
-
}
|
|
40
|
-
json.methodName = lines[json.operationName ? 1 : 0].split('(')[0].split('{')[0];
|
|
41
|
-
return json;
|
|
42
|
-
} catch (error) {
|
|
43
|
-
logger.debug(error, 'unable to parse GQL');
|
|
44
|
-
}
|
|
45
|
-
return undefined;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
// TODO add proper web proxy mode
|
|
49
|
-
|
|
50
|
-
export class Mock {
|
|
51
|
-
protected mockUuid: string;
|
|
52
|
-
protected options: Stuntman.ServerConfig;
|
|
53
|
-
protected mockApp: express.Express;
|
|
54
|
-
protected MOCK_DOMAIN_REGEX: RegExp;
|
|
55
|
-
protected URL_PORT_REGEX: RegExp;
|
|
56
|
-
protected server: http.Server | null = null;
|
|
57
|
-
protected serverHttps: https.Server | null = null;
|
|
58
|
-
protected trafficStore: LRUCache<string, Stuntman.LogEntry>;
|
|
59
|
-
protected ipUtils: IPUtils | null = null;
|
|
60
|
-
private _api: API | null = null;
|
|
61
|
-
|
|
62
|
-
get apiServer() {
|
|
63
|
-
if (this.options.api.disabled) {
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
if (!this._api) {
|
|
67
|
-
this._api = new API({ ...this.options.api, mockUuid: this.mockUuid }, this.options.webgui);
|
|
68
|
-
}
|
|
69
|
-
return this._api;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
constructor(options: Stuntman.ServerConfig) {
|
|
73
|
-
this.mockUuid = uuidv4();
|
|
74
|
-
this.options = options;
|
|
75
|
-
if (this.options.mock.httpsPort && (!this.options.mock.httpsKey || !this.options.mock.httpsCert)) {
|
|
76
|
-
throw new Error('missing https key/cert');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
this.MOCK_DOMAIN_REGEX = new RegExp(
|
|
80
|
-
`(?:\\.([0-9]+))?\\.(?:(?:${this.options.mock.domain})|(?:localhost))(https?)?(:${this.options.mock.port}${
|
|
81
|
-
this.options.mock.httpsPort ? `|${this.options.mock.httpsPort}` : ''
|
|
82
|
-
})?(?:\\b|$)`,
|
|
83
|
-
'i'
|
|
84
|
-
);
|
|
85
|
-
this.URL_PORT_REGEX = new RegExp(
|
|
86
|
-
`^(https?:\\/\\/[^:/]+):(?:${this.options.mock.port}${
|
|
87
|
-
this.options.mock.httpsPort ? `|${this.options.mock.httpsPort}` : ''
|
|
88
|
-
})(\\/.*)`,
|
|
89
|
-
'i'
|
|
90
|
-
);
|
|
91
|
-
this.trafficStore = getTrafficStore(this.mockUuid, this.options.storage.traffic);
|
|
92
|
-
this.ipUtils =
|
|
93
|
-
!this.options.mock.externalDns || this.options.mock.externalDns.length === 0
|
|
94
|
-
? null
|
|
95
|
-
: new IPUtils({ mockUuid: this.mockUuid, externalDns: this.options.mock.externalDns });
|
|
96
|
-
|
|
97
|
-
this.mockApp = express();
|
|
98
|
-
// TODO for now request body is just a buffer passed further, not inflated
|
|
99
|
-
this.mockApp.use(express.raw({ type: '*/*' }));
|
|
100
|
-
|
|
101
|
-
this.mockApp.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
102
|
-
RequestContext.bind(req, this.mockUuid);
|
|
103
|
-
next();
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
this.mockApp.all(/.*/, async (req, res) => {
|
|
107
|
-
const ctx: RequestContext | null = RequestContext.get(req);
|
|
108
|
-
const requestUuid = ctx?.uuid || uuidv4();
|
|
109
|
-
const timestamp = Date.now();
|
|
110
|
-
const originalHostname = req.hostname;
|
|
111
|
-
const unproxiedHostname = req.hostname.replace(this.MOCK_DOMAIN_REGEX, '');
|
|
112
|
-
const isProxiedHostname = originalHostname !== unproxiedHostname;
|
|
113
|
-
const originalRequest = {
|
|
114
|
-
id: requestUuid,
|
|
115
|
-
timestamp,
|
|
116
|
-
url: `${req.protocol}://${req.hostname}${req.originalUrl}`,
|
|
117
|
-
method: req.method,
|
|
118
|
-
rawHeaders: new RawHeaders(...req.rawHeaders),
|
|
119
|
-
...(Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }),
|
|
120
|
-
};
|
|
121
|
-
logger.debug(originalRequest, 'processing request');
|
|
122
|
-
const logContext: Record<string, any> = {
|
|
123
|
-
requestId: originalRequest.id,
|
|
124
|
-
};
|
|
125
|
-
const mockEntry: WithRequiredProperty<Stuntman.LogEntry, 'modifiedRequest'> = {
|
|
126
|
-
originalRequest,
|
|
127
|
-
modifiedRequest: {
|
|
128
|
-
...this.unproxyRequest(req),
|
|
129
|
-
id: requestUuid,
|
|
130
|
-
timestamp,
|
|
131
|
-
...(originalRequest.body && { gqlBody: naiveGQLParser(originalRequest.body) }),
|
|
132
|
-
},
|
|
133
|
-
};
|
|
134
|
-
if (!isProxiedHostname) {
|
|
135
|
-
this.removeProxyPort(mockEntry.modifiedRequest);
|
|
136
|
-
}
|
|
137
|
-
const matchingRule = await ruleExecutor.findMatchingRule(mockEntry.modifiedRequest);
|
|
138
|
-
if (matchingRule) {
|
|
139
|
-
mockEntry.mockRuleId = matchingRule.id;
|
|
140
|
-
mockEntry.labels = matchingRule.labels;
|
|
141
|
-
if (matchingRule.actions?.mockResponse) {
|
|
142
|
-
const staticResponse =
|
|
143
|
-
typeof matchingRule.actions.mockResponse === 'function'
|
|
144
|
-
? matchingRule.actions.mockResponse(mockEntry.modifiedRequest)
|
|
145
|
-
: matchingRule.actions.mockResponse;
|
|
146
|
-
mockEntry.modifiedResponse = staticResponse;
|
|
147
|
-
logger.debug({ ...logContext, staticResponse }, 'replying with mocked response');
|
|
148
|
-
if (matchingRule.storeTraffic) {
|
|
149
|
-
this.trafficStore.set(requestUuid, mockEntry);
|
|
150
|
-
}
|
|
151
|
-
if (staticResponse.rawHeaders) {
|
|
152
|
-
for (const header of staticResponse.rawHeaders.toHeaderPairs()) {
|
|
153
|
-
res.setHeader(header[0], header[1]);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
res.status(staticResponse.status || 200);
|
|
157
|
-
res.send(staticResponse.body);
|
|
158
|
-
// static response blocks any further processing
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
if (matchingRule.actions?.modifyRequest) {
|
|
162
|
-
mockEntry.modifiedRequest = matchingRule.actions?.modifyRequest(mockEntry.modifiedRequest);
|
|
163
|
-
logger.debug({ ...logContext, modifiedRequest: mockEntry.modifiedRequest }, 'modified original request');
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
if (this.ipUtils && !isProxiedHostname && !this.ipUtils.isIP(originalHostname)) {
|
|
167
|
-
const hostname = originalHostname.split(':')[0];
|
|
168
|
-
try {
|
|
169
|
-
const internalIPs = await this.ipUtils.resolveIP(hostname);
|
|
170
|
-
if (this.ipUtils.isLocalhostIP(internalIPs) && this.options.mock.externalDns.length) {
|
|
171
|
-
const externalIPs = await this.ipUtils.resolveIP(hostname, { useExternalDns: true });
|
|
172
|
-
logger.debug({ ...logContext, hostname, externalIPs, internalIPs }, 'switched to external IP');
|
|
173
|
-
mockEntry.modifiedRequest.url = mockEntry.modifiedRequest.url.replace(
|
|
174
|
-
/^(https?:\/\/)[^:/]+/i,
|
|
175
|
-
`$1${externalIPs}`
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
} catch (error) {
|
|
179
|
-
// swallow the exeception, don't think much can be done at this point
|
|
180
|
-
logger.warn({ ...logContext, error }, `error trying to resolve IP for "${hostname}"`);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
let controller: AbortController | null = new AbortController();
|
|
185
|
-
const fetchTimeout = setTimeout(() => {
|
|
186
|
-
if (controller) {
|
|
187
|
-
controller.abort(`timeout after ${this.options.mock.timeout}`);
|
|
188
|
-
}
|
|
189
|
-
}, this.options.mock.timeout);
|
|
190
|
-
req.on('close', () => {
|
|
191
|
-
logger.debug(logContext, 'remote client canceled the request');
|
|
192
|
-
clearTimeout(fetchTimeout);
|
|
193
|
-
if (controller) {
|
|
194
|
-
controller.abort('remote client canceled the request');
|
|
195
|
-
}
|
|
196
|
-
});
|
|
197
|
-
let targetResponse: Response;
|
|
198
|
-
const hasKeepAlive = !!mockEntry.modifiedRequest.rawHeaders
|
|
199
|
-
.toHeaderPairs()
|
|
200
|
-
.find((h) => /^connection$/.test(h[0]) && /^keep-alive$/.test(h[1]));
|
|
201
|
-
try {
|
|
202
|
-
targetResponse = await fetch(mockEntry.modifiedRequest.url, {
|
|
203
|
-
redirect: 'manual',
|
|
204
|
-
headers: mockEntry.modifiedRequest.rawHeaders
|
|
205
|
-
.toHeaderPairs()
|
|
206
|
-
.filter((h) => !/^connection$/.test(h[0]) && !/^keep-alive$/.test(h[1])),
|
|
207
|
-
body: mockEntry.modifiedRequest.body,
|
|
208
|
-
method: mockEntry.modifiedRequest.method,
|
|
209
|
-
keepalive: !!hasKeepAlive,
|
|
210
|
-
});
|
|
211
|
-
} finally {
|
|
212
|
-
controller = null;
|
|
213
|
-
clearTimeout(fetchTimeout);
|
|
214
|
-
}
|
|
215
|
-
const targetResponseBuffer = Buffer.from(await targetResponse.arrayBuffer());
|
|
216
|
-
const originalResponse = {
|
|
217
|
-
timestamp: Date.now(),
|
|
218
|
-
body: targetResponseBuffer.toString('binary'),
|
|
219
|
-
status: targetResponse.status,
|
|
220
|
-
rawHeaders: new RawHeaders(
|
|
221
|
-
...Array.from(targetResponse.headers.entries()).flatMap(([key, value]) => [key, value])
|
|
222
|
-
),
|
|
223
|
-
};
|
|
224
|
-
logger.debug({ ...logContext, originalResponse }, 'received response');
|
|
225
|
-
mockEntry.originalResponse = originalResponse;
|
|
226
|
-
let modifedResponse: Stuntman.Response = {
|
|
227
|
-
...originalResponse,
|
|
228
|
-
rawHeaders: new RawHeaders(
|
|
229
|
-
...Array.from(targetResponse.headers.entries()).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(
|
|
236
|
-
new RegExp(`(?:^|\\b)(${unproxiedHostname.replace('.', '\\.')})(?:\\b|$)`, 'igm'),
|
|
237
|
-
originalHostname
|
|
238
|
-
),
|
|
239
|
-
];
|
|
240
|
-
})
|
|
241
|
-
),
|
|
242
|
-
};
|
|
243
|
-
if (matchingRule?.actions?.modifyResponse) {
|
|
244
|
-
modifedResponse = matchingRule?.actions?.modifyResponse(mockEntry.modifiedRequest, originalResponse);
|
|
245
|
-
logger.debug({ ...logContext, modifedResponse }, 'modified response');
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
mockEntry.modifiedResponse = modifedResponse;
|
|
249
|
-
if (matchingRule?.storeTraffic) {
|
|
250
|
-
this.trafficStore.set(requestUuid, mockEntry);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (modifedResponse.status) {
|
|
254
|
-
res.status(modifedResponse.status);
|
|
255
|
-
}
|
|
256
|
-
if (modifedResponse.rawHeaders) {
|
|
257
|
-
for (const header of modifedResponse.rawHeaders.toHeaderPairs()) {
|
|
258
|
-
// since fetch decompresses responses we need to get rid of some headers
|
|
259
|
-
// TODO maybe could be handled better than just skipping, although express should add these back for new body
|
|
260
|
-
if (/^content-(?:length|encoding)$/i.test(header[0])) {
|
|
261
|
-
continue;
|
|
262
|
-
}
|
|
263
|
-
res.setHeader(header[0], header[1]);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
res.end(Buffer.from(modifedResponse.body, 'binary'));
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
this.mockApp.use((error: Error, req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
270
|
-
const ctx: RequestContext | null = RequestContext.get(req);
|
|
271
|
-
const uuid = ctx?.uuid || uuidv4();
|
|
272
|
-
logger.error({ ...error, uuid }, 'Unexpected error');
|
|
273
|
-
if (res) {
|
|
274
|
-
res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
|
|
275
|
-
error: { message: error.message, httpCode: HttpCode.INTERNAL_SERVER_ERROR, uuid },
|
|
276
|
-
});
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
console.log('mock encountered a critical error. exiting');
|
|
280
|
-
process.exit(1);
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
public start() {
|
|
285
|
-
if (this.server) {
|
|
286
|
-
throw new Error('mock server already started');
|
|
287
|
-
}
|
|
288
|
-
if (this.options.mock.httpsPort) {
|
|
289
|
-
this.serverHttps = https
|
|
290
|
-
.createServer(
|
|
291
|
-
{
|
|
292
|
-
key: this.options.mock.httpsKey,
|
|
293
|
-
cert: this.options.mock.httpsCert,
|
|
294
|
-
},
|
|
295
|
-
this.mockApp
|
|
296
|
-
)
|
|
297
|
-
.listen(this.options.mock.httpsPort, () => {
|
|
298
|
-
logger.info(`Mock listening on ${this.options.mock.domain}:${this.options.mock.httpsPort}`);
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
this.server = this.mockApp.listen(this.options.mock.port, () => {
|
|
302
|
-
logger.info(`Mock listening on ${this.options.mock.domain}:${this.options.mock.port}`);
|
|
303
|
-
if (!this.options.api.disabled) {
|
|
304
|
-
this.apiServer?.start();
|
|
305
|
-
}
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
public stop() {
|
|
310
|
-
if (!this.server) {
|
|
311
|
-
throw new Error('mock server not started');
|
|
312
|
-
}
|
|
313
|
-
if (!this.options.api.disabled) {
|
|
314
|
-
this.apiServer?.stop();
|
|
315
|
-
}
|
|
316
|
-
this.server.close((error) => {
|
|
317
|
-
logger.warn(error, 'problem closing server');
|
|
318
|
-
this.server = null;
|
|
319
|
-
});
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
protected unproxyRequest(req: express.Request): Stuntman.BaseRequest {
|
|
323
|
-
const protocol = (this.MOCK_DOMAIN_REGEX.exec(req.hostname) || [])[2] || req.protocol;
|
|
324
|
-
const port = (this.MOCK_DOMAIN_REGEX.exec(req.hostname) || [])[1] || undefined;
|
|
325
|
-
|
|
326
|
-
// TODO unproxied req might fail if there's a signed url :shrug:
|
|
327
|
-
// but then we can probably switch DNS for some particular 3rd party server to point to mock
|
|
328
|
-
// and in mock have a mapping rule for that domain to point directly to some IP :thinking:
|
|
329
|
-
return {
|
|
330
|
-
url: `${protocol}://${req.hostname.replace(this.MOCK_DOMAIN_REGEX, '')}${port ? `:${port}` : ''}${req.originalUrl}`,
|
|
331
|
-
rawHeaders: new RawHeaders(...req.rawHeaders.map((h) => h.replace(this.MOCK_DOMAIN_REGEX, ''))),
|
|
332
|
-
method: req.method,
|
|
333
|
-
...(Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }),
|
|
334
|
-
};
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
protected removeProxyPort(req: Stuntman.Request): void {
|
|
338
|
-
if (this.URL_PORT_REGEX.test(req.url)) {
|
|
339
|
-
req.url = req.url.replace(this.URL_PORT_REGEX, '$1$2');
|
|
340
|
-
}
|
|
341
|
-
const host = req.rawHeaders.get('host') || '';
|
|
342
|
-
if (
|
|
343
|
-
host.endsWith(`:${this.options.mock.port}`) ||
|
|
344
|
-
(this.options.mock.httpsPort && host.endsWith(`:${this.options.mock.httpsPort}`))
|
|
345
|
-
) {
|
|
346
|
-
req.rawHeaders.set('host', host.split(':')[0]);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|