@stuntman/server 0.1.0

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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/config/default.json +23 -0
  3. package/config/test.json +26 -0
  4. package/dist/api/api.d.ts +19 -0
  5. package/dist/api/api.js +162 -0
  6. package/dist/api/utils.d.ts +4 -0
  7. package/dist/api/utils.js +69 -0
  8. package/dist/api/validatiors.d.ts +3 -0
  9. package/dist/api/validatiors.js +118 -0
  10. package/dist/api/webgui/rules.pug +145 -0
  11. package/dist/api/webgui/style.css +28 -0
  12. package/dist/api/webgui/traffic.pug +37 -0
  13. package/dist/bin/stuntman.d.ts +2 -0
  14. package/dist/bin/stuntman.js +7 -0
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +5 -0
  17. package/dist/ipUtils.d.ts +17 -0
  18. package/dist/ipUtils.js +101 -0
  19. package/dist/mock.d.ts +27 -0
  20. package/dist/mock.js +308 -0
  21. package/dist/requestContext.d.ts +9 -0
  22. package/dist/requestContext.js +18 -0
  23. package/dist/ruleExecutor.d.ts +21 -0
  24. package/dist/ruleExecutor.js +172 -0
  25. package/dist/rules/catchAll.d.ts +2 -0
  26. package/dist/rules/catchAll.js +15 -0
  27. package/dist/rules/echo.d.ts +2 -0
  28. package/dist/rules/echo.js +15 -0
  29. package/dist/rules/index.d.ts +2 -0
  30. package/dist/rules/index.js +7 -0
  31. package/dist/storage.d.ts +4 -0
  32. package/dist/storage.js +42 -0
  33. package/package.json +58 -0
  34. package/src/api/api.ts +194 -0
  35. package/src/api/utils.ts +69 -0
  36. package/src/api/validatiors.ts +123 -0
  37. package/src/api/webgui/rules.pug +145 -0
  38. package/src/api/webgui/style.css +28 -0
  39. package/src/api/webgui/traffic.pug +37 -0
  40. package/src/bin/stuntman.ts +8 -0
  41. package/src/index.ts +1 -0
  42. package/src/ipUtils.ts +83 -0
  43. package/src/mock.ts +349 -0
  44. package/src/requestContext.ts +23 -0
  45. package/src/ruleExecutor.ts +193 -0
  46. package/src/rules/catchAll.ts +14 -0
  47. package/src/rules/echo.ts +14 -0
  48. package/src/rules/index.ts +7 -0
  49. package/src/storage.ts +39 -0
  50. package/tsconfig.json +16 -0
@@ -0,0 +1,123 @@
1
+ import { AppError, HttpCode, MAX_RULE_TTL_SECONDS, MIN_RULE_TTL_SECONDS, logger, RawHeaders } from '@stuntman/shared';
2
+ import type * as Stuntman from '@stuntman/shared';
3
+
4
+ export const validateSerializedRuleProperties = (rule: Stuntman.SerializedRule): void => {
5
+ if (!rule) {
6
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'invalid serialized rule' });
7
+ }
8
+ if (typeof rule.id !== 'string') {
9
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'invalid rule.id' });
10
+ }
11
+ if (typeof rule.matches !== 'object' || !('remoteFn' in rule.matches) || typeof rule.matches.remoteFn !== 'string') {
12
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'invalid rule.matches' });
13
+ }
14
+ if (rule.priority && typeof rule.priority !== 'number') {
15
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'invalid rule.priority' });
16
+ }
17
+ if (typeof rule.actions !== 'undefined') {
18
+ if (typeof rule.actions !== 'object') {
19
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'invalid rule.actions' });
20
+ }
21
+ if (typeof rule.actions.mockResponse !== 'undefined') {
22
+ if (typeof rule.actions.mockResponse !== 'object') {
23
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'invalid rule.actions.mockResponse' });
24
+ }
25
+ if ('remoteFn' in rule.actions.mockResponse && typeof rule.actions.mockResponse.remoteFn !== 'string') {
26
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'invalid rule.actions.mockResponse' });
27
+ } else if ('status' in rule.actions.mockResponse) {
28
+ if (typeof rule.actions.mockResponse.status !== 'number') {
29
+ throw new AppError({
30
+ httpCode: HttpCode.BAD_REQUEST,
31
+ message: 'invalid rule.actions.mockResponse.status',
32
+ });
33
+ }
34
+ if (
35
+ typeof rule.actions.mockResponse.rawHeaders !== 'undefined' &&
36
+ (!Array.isArray(rule.actions.mockResponse.rawHeaders) ||
37
+ rule.actions.mockResponse.rawHeaders.some((header) => typeof header !== 'string'))
38
+ ) {
39
+ throw new AppError({
40
+ httpCode: HttpCode.BAD_REQUEST,
41
+ message: 'invalid rule.actions.mockResponse.rawHeaders',
42
+ });
43
+ }
44
+ if (typeof rule.actions.mockResponse.body !== 'undefined' && typeof rule.actions.mockResponse.body !== 'string') {
45
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'invalid rule.actions.mockResponse.body' });
46
+ }
47
+ }
48
+ }
49
+ if (typeof rule.actions.modifyRequest !== 'undefined' && typeof rule.actions.modifyRequest.remoteFn !== 'string') {
50
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'invalid rule.actions.modifyRequest' });
51
+ }
52
+ if (typeof rule.actions.modifyResponse !== 'undefined' && typeof rule.actions.modifyResponse.remoteFn !== 'string') {
53
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'invalid rule.actions.modifyResponse' });
54
+ }
55
+ }
56
+ if (
57
+ typeof rule.disableAfterUse !== 'undefined' &&
58
+ typeof rule.disableAfterUse !== 'boolean' &&
59
+ (typeof rule.disableAfterUse !== 'number' || rule.disableAfterUse < 0)
60
+ ) {
61
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'invalid rule.disableAfterUse' });
62
+ }
63
+ if (
64
+ typeof rule.removeAfterUse !== 'undefined' &&
65
+ typeof rule.removeAfterUse !== 'boolean' &&
66
+ (typeof rule.removeAfterUse !== 'number' || rule.removeAfterUse < 0)
67
+ ) {
68
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'invalid rule.removeAfterUse' });
69
+ }
70
+ if (
71
+ typeof rule.labels !== 'undefined' &&
72
+ (!Array.isArray(rule.labels) || rule.labels.some((label) => typeof label !== 'string'))
73
+ ) {
74
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'invalid rule.labels' });
75
+ }
76
+ if (rule.actions?.mockResponse && rule.actions.modifyResponse) {
77
+ throw new AppError({
78
+ httpCode: HttpCode.BAD_REQUEST,
79
+ message: 'rule.actions.mockResponse and rule.actions.modifyResponse are mutually exclusive',
80
+ });
81
+ }
82
+ if (!rule.ttlSeconds || rule.ttlSeconds < MIN_RULE_TTL_SECONDS || rule.ttlSeconds > MAX_RULE_TTL_SECONDS) {
83
+ throw new AppError({
84
+ httpCode: HttpCode.BAD_REQUEST,
85
+ message: `rule.ttlSeconds should be within ${MIN_RULE_TTL_SECONDS} and ${MAX_RULE_TTL_SECONDS}`,
86
+ });
87
+ }
88
+ if (typeof rule.isEnabled !== 'undefined' && typeof rule.isEnabled !== 'boolean') {
89
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'invalid rule.isEnabled' });
90
+ }
91
+ if (typeof rule.storeTraffic !== 'undefined' && typeof rule.storeTraffic !== 'boolean') {
92
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'invalid rule.storeTraffic' });
93
+ }
94
+ };
95
+
96
+ export const validateDeserializedRule = (deserializedRule: Stuntman.Rule) => {
97
+ // TODO validate other functions ?
98
+ let matchValidationResult: Stuntman.RuleMatchResult;
99
+ try {
100
+ matchValidationResult = deserializedRule.matches({
101
+ id: 'validation',
102
+ method: 'GET',
103
+ rawHeaders: new RawHeaders(),
104
+ timestamp: Date.now(),
105
+ url: 'http://dummy.invalid/',
106
+ });
107
+ } catch (error: any) {
108
+ logger.error({ ruleId: deserializedRule.id }, error);
109
+ throw new AppError({
110
+ httpCode: HttpCode.UNPROCESSABLE_ENTITY,
111
+ message: 'match function returned invalid value',
112
+ });
113
+ }
114
+ if (
115
+ matchValidationResult !== true &&
116
+ matchValidationResult !== false &&
117
+ matchValidationResult.result !== true &&
118
+ matchValidationResult.result !== false
119
+ ) {
120
+ logger.error({ ruleId: deserializedRule.id, matchValidationResult }, 'match function retruned invalid value');
121
+ throw new AppError({ httpCode: HttpCode.UNPROCESSABLE_ENTITY, message: 'match function returned invalid value' });
122
+ }
123
+ };
@@ -0,0 +1,145 @@
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
+ });
@@ -0,0 +1,28 @@
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
+ }
@@ -0,0 +1,37 @@
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
+ });
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Mock } from '../mock';
4
+ import { serverConfig } from '@stuntman/shared';
5
+
6
+ const mock = new Mock(serverConfig);
7
+
8
+ mock.start();
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { Mock as StuntmanMock } from './mock';
package/src/ipUtils.ts ADDED
@@ -0,0 +1,83 @@
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
+ }