@stuntman/server 0.1.4 → 0.1.6
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.d.ts +22 -0
- package/dist/api/api.js +182 -0
- package/dist/api/utils.d.ts +4 -0
- package/dist/api/utils.js +60 -0
- package/dist/api/validators.d.ts +3 -0
- package/dist/api/validators.js +124 -0
- package/dist/api/webgui/rules.pug +147 -0
- package/dist/api/webgui/style.css +28 -0
- package/dist/api/webgui/traffic.pug +37 -0
- package/dist/bin/stuntman.d.ts +2 -0
- package/dist/bin/stuntman.js +7 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/ipUtils.d.ts +17 -0
- package/dist/ipUtils.js +101 -0
- package/dist/mock.d.ts +30 -0
- package/dist/mock.js +327 -0
- package/dist/requestContext.d.ts +9 -0
- package/dist/requestContext.js +18 -0
- package/dist/ruleExecutor.d.ts +22 -0
- package/dist/ruleExecutor.js +187 -0
- package/dist/rules/catchAll.d.ts +2 -0
- package/dist/rules/catchAll.js +15 -0
- package/dist/rules/echo.d.ts +2 -0
- package/dist/rules/echo.js +15 -0
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.js +70 -0
- package/dist/storage.d.ts +4 -0
- package/dist/storage.js +42 -0
- package/package.json +8 -5
- package/src/api/api.ts +225 -0
- package/src/api/utils.ts +69 -0
- package/src/api/validators.ts +132 -0
- package/src/api/webgui/rules.pug +147 -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 +382 -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
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getRuleExecutor = void 0;
|
|
7
|
+
const await_lock_1 = __importDefault(require("await-lock"));
|
|
8
|
+
const shared_1 = require("@stuntman/shared");
|
|
9
|
+
const rules_1 = require("./rules");
|
|
10
|
+
const ruleExecutors = {};
|
|
11
|
+
const transformMockRuleToLive = (rule) => {
|
|
12
|
+
var _a;
|
|
13
|
+
return {
|
|
14
|
+
...rule,
|
|
15
|
+
counter: 0,
|
|
16
|
+
isEnabled: (_a = rule.isEnabled) !== null && _a !== void 0 ? _a : true,
|
|
17
|
+
createdTimestamp: Date.now(),
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
class RuleExecutor {
|
|
21
|
+
get enabledRules() {
|
|
22
|
+
if (!this._rules) {
|
|
23
|
+
return new Array();
|
|
24
|
+
}
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
return this._rules
|
|
27
|
+
.filter((r) => (r.isEnabled && !Number.isFinite(r.ttlSeconds)) || r.createdTimestamp + r.ttlSeconds * 1000 > now)
|
|
28
|
+
.sort((a, b) => { var _a, _b; return ((_a = a.priority) !== null && _a !== void 0 ? _a : shared_1.DEFAULT_RULE_PRIORITY) - ((_b = b.priority) !== null && _b !== void 0 ? _b : shared_1.DEFAULT_RULE_PRIORITY); });
|
|
29
|
+
}
|
|
30
|
+
constructor(rules) {
|
|
31
|
+
this.rulesLock = new await_lock_1.default();
|
|
32
|
+
this._rules = (rules || []).map(transformMockRuleToLive);
|
|
33
|
+
}
|
|
34
|
+
hasExpired() {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
return this._rules.some((r) => Number.isFinite(r.ttlSeconds) && r.createdTimestamp + r.ttlSeconds * 1000 < now);
|
|
37
|
+
}
|
|
38
|
+
async cleanUpExpired() {
|
|
39
|
+
if (!this.hasExpired()) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
await this.rulesLock.acquireAsync();
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
try {
|
|
45
|
+
this._rules = this._rules.filter((r) => {
|
|
46
|
+
const shouldKeep = !Number.isFinite(r.ttlSeconds) || r.createdTimestamp + r.ttlSeconds * 1000 > now;
|
|
47
|
+
if (!shouldKeep) {
|
|
48
|
+
shared_1.logger.debug({ ruleId: r.id }, 'removing expired rule');
|
|
49
|
+
}
|
|
50
|
+
return shouldKeep;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
await this.rulesLock.release();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async addRule(rule, overwrite) {
|
|
58
|
+
await this.cleanUpExpired();
|
|
59
|
+
await this.rulesLock.acquireAsync();
|
|
60
|
+
try {
|
|
61
|
+
if (this._rules.some((r) => r.id === rule.id)) {
|
|
62
|
+
if (!overwrite) {
|
|
63
|
+
throw new shared_1.AppError({ httpCode: shared_1.HttpCode.CONFLICT, message: 'rule with given ID already exists' });
|
|
64
|
+
}
|
|
65
|
+
this._removeRule(rule.id);
|
|
66
|
+
}
|
|
67
|
+
const liveRule = transformMockRuleToLive(rule);
|
|
68
|
+
this._rules.push(liveRule);
|
|
69
|
+
shared_1.logger.debug(liveRule, 'rule added');
|
|
70
|
+
return liveRule;
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
await this.rulesLock.release();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
_removeRule(ruleOrId) {
|
|
77
|
+
this._rules = this._rules.filter((r) => {
|
|
78
|
+
const notFound = r.id !== (typeof ruleOrId === 'string' ? ruleOrId : ruleOrId.id);
|
|
79
|
+
if (!notFound) {
|
|
80
|
+
shared_1.logger.debug({ ruleId: r.id }, 'rule removed');
|
|
81
|
+
}
|
|
82
|
+
return notFound;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async removeRule(ruleOrId) {
|
|
86
|
+
await this.cleanUpExpired();
|
|
87
|
+
await this.rulesLock.acquireAsync();
|
|
88
|
+
try {
|
|
89
|
+
this._removeRule(ruleOrId);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
await this.rulesLock.release();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
enableRule(ruleOrId) {
|
|
96
|
+
this._rules.forEach((r) => {
|
|
97
|
+
if (r.id === (typeof ruleOrId === 'string' ? ruleOrId : ruleOrId.id)) {
|
|
98
|
+
r.counter = 0;
|
|
99
|
+
r.isEnabled = true;
|
|
100
|
+
shared_1.logger.debug({ ruleId: r.id }, 'rule enabled');
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
disableRule(ruleOrId) {
|
|
105
|
+
this._rules.forEach((r) => {
|
|
106
|
+
if (r.id === (typeof ruleOrId === 'string' ? ruleOrId : ruleOrId.id)) {
|
|
107
|
+
r.isEnabled = false;
|
|
108
|
+
shared_1.logger.debug({ ruleId: r.id }, 'rule disabled');
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async findMatchingRule(request) {
|
|
113
|
+
const logContext = {
|
|
114
|
+
requestId: request.id,
|
|
115
|
+
};
|
|
116
|
+
const matchingRule = this.enabledRules.find((rule) => {
|
|
117
|
+
try {
|
|
118
|
+
const matchResult = rule.matches(request);
|
|
119
|
+
shared_1.logger.trace({ ...logContext, matchResult }, `rule match attempt for ${rule.id}`);
|
|
120
|
+
if (typeof matchResult === 'boolean') {
|
|
121
|
+
return matchResult;
|
|
122
|
+
}
|
|
123
|
+
return matchResult.result;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
shared_1.logger.error({ ...logContext, ruleId: rule === null || rule === void 0 ? void 0 : rule.id, error }, 'error in rule match function');
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
if (!matchingRule) {
|
|
130
|
+
shared_1.logger.debug(logContext, 'no matching rule found');
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const matchResult = matchingRule.matches(request);
|
|
134
|
+
logContext.ruleId = matchingRule.id;
|
|
135
|
+
shared_1.logger.debug({ ...logContext, matchResultMessage: typeof matchResult !== 'boolean' ? matchResult.description : null }, 'found matching rule');
|
|
136
|
+
const matchingRuleClone = Object.freeze(Object.assign({}, matchingRule));
|
|
137
|
+
++matchingRule.counter;
|
|
138
|
+
logContext.ruleCounter = matchingRule.counter;
|
|
139
|
+
if (Number.isNaN(matchingRule.counter) || !Number.isFinite(matchingRule.counter)) {
|
|
140
|
+
matchingRule.counter = 0;
|
|
141
|
+
shared_1.logger.warn(logContext, "it's over 9000!!!");
|
|
142
|
+
}
|
|
143
|
+
// TODO check if that works
|
|
144
|
+
if (matchingRule.disableAfterUse) {
|
|
145
|
+
if (typeof matchingRule.disableAfterUse === 'boolean' || matchingRule.disableAfterUse <= matchingRule.counter) {
|
|
146
|
+
shared_1.logger.debug(logContext, 'disabling rule for future requests');
|
|
147
|
+
this.disableRule(matchingRule);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (matchingRule.removeAfterUse) {
|
|
151
|
+
if (typeof matchingRule.removeAfterUse === 'boolean' || matchingRule.removeAfterUse <= matchingRule.counter) {
|
|
152
|
+
shared_1.logger.debug(logContext, 'removing rule for future requests');
|
|
153
|
+
await this.removeRule(matchingRule);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (typeof matchResult !== 'boolean') {
|
|
157
|
+
if (matchResult.disableRuleIds && matchResult.disableRuleIds.length > 0) {
|
|
158
|
+
shared_1.logger.debug({ ...logContext, disableRuleIds: matchResult.disableRuleIds }, 'disabling rules based on matchResult');
|
|
159
|
+
for (const ruleId of matchResult.disableRuleIds) {
|
|
160
|
+
this.disableRule(ruleId);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (matchResult.enableRuleIds && matchResult.enableRuleIds.length > 0) {
|
|
164
|
+
shared_1.logger.debug({ ...logContext, disableRuleIds: matchResult.disableRuleIds }, 'enabling rules based on matchResult');
|
|
165
|
+
for (const ruleId of matchResult.enableRuleIds) {
|
|
166
|
+
this.enableRule(ruleId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return matchingRuleClone;
|
|
171
|
+
}
|
|
172
|
+
async getRules() {
|
|
173
|
+
await this.cleanUpExpired();
|
|
174
|
+
return this._rules;
|
|
175
|
+
}
|
|
176
|
+
async getRule(id) {
|
|
177
|
+
await this.cleanUpExpired();
|
|
178
|
+
return this._rules.find((r) => r.id === id);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const getRuleExecutor = (mockUuid) => {
|
|
182
|
+
if (!ruleExecutors[mockUuid]) {
|
|
183
|
+
ruleExecutors[mockUuid] = new RuleExecutor([...rules_1.DEFAULT_RULES, ...rules_1.CUSTOM_RULES].map((r) => ({ ...r, ttlSeconds: Infinity })));
|
|
184
|
+
}
|
|
185
|
+
return ruleExecutors[mockUuid];
|
|
186
|
+
};
|
|
187
|
+
exports.getRuleExecutor = getRuleExecutor;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.catchAllRule = void 0;
|
|
4
|
+
const shared_1 = require("@stuntman/shared");
|
|
5
|
+
exports.catchAllRule = {
|
|
6
|
+
id: shared_1.CATCH_RULE_NAME,
|
|
7
|
+
matches: () => true,
|
|
8
|
+
priority: shared_1.CATCH_ALL_RULE_PRIORITY,
|
|
9
|
+
actions: {
|
|
10
|
+
mockResponse: (req) => ({
|
|
11
|
+
body: `Request received by Stuntman mock <pre>${JSON.stringify(req, null, 4)}</pre>`,
|
|
12
|
+
status: 200,
|
|
13
|
+
}),
|
|
14
|
+
},
|
|
15
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.echoRule = void 0;
|
|
4
|
+
const shared_1 = require("@stuntman/shared");
|
|
5
|
+
exports.echoRule = {
|
|
6
|
+
id: 'internal/echo',
|
|
7
|
+
priority: shared_1.DEFAULT_RULE_PRIORITY + 1,
|
|
8
|
+
matches: (req) => /https?:\/\/echo\/.*/.test(req.url),
|
|
9
|
+
actions: {
|
|
10
|
+
mockResponse: (req) => ({
|
|
11
|
+
body: req,
|
|
12
|
+
status: 200,
|
|
13
|
+
}),
|
|
14
|
+
},
|
|
15
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
19
|
+
if (mod && mod.__esModule) return mod;
|
|
20
|
+
var result = {};
|
|
21
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
+
__setModuleDefault(result, mod);
|
|
23
|
+
return result;
|
|
24
|
+
};
|
|
25
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
+
};
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.CUSTOM_RULES = exports.DEFAULT_RULES = void 0;
|
|
30
|
+
const fs_1 = __importDefault(require("fs"));
|
|
31
|
+
const glob_1 = __importDefault(require("glob"));
|
|
32
|
+
const tsImport = __importStar(require("ts-import"));
|
|
33
|
+
const catchAll_1 = require("./catchAll");
|
|
34
|
+
const echo_1 = require("./echo");
|
|
35
|
+
const shared_1 = require("@stuntman/shared");
|
|
36
|
+
exports.DEFAULT_RULES = [catchAll_1.catchAllRule, echo_1.echoRule];
|
|
37
|
+
exports.CUSTOM_RULES = [];
|
|
38
|
+
const loadAdditionalRules = () => {
|
|
39
|
+
if (!shared_1.serverConfig.mock.rulesPath || !fs_1.default.existsSync(shared_1.serverConfig.mock.rulesPath)) {
|
|
40
|
+
shared_1.logger.debug({ rulesPath: shared_1.serverConfig.mock.rulesPath }, `additional rules directory not found`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
shared_1.logger.debug({ rulesPath: shared_1.serverConfig.mock.rulesPath }, `loading additional rules`);
|
|
44
|
+
const filePaths = glob_1.default.sync('*.[tj]s', { absolute: true, cwd: shared_1.serverConfig.mock.rulesPath });
|
|
45
|
+
for (const filePath of filePaths) {
|
|
46
|
+
// TODO add .ts rule support
|
|
47
|
+
try {
|
|
48
|
+
const loadedFile = /\.js$/.test(filePath) ? require(filePath) : tsImport.loadSync(filePath);
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
50
|
+
const exportedRules = Object.values(loadedFile).filter((rule) => {
|
|
51
|
+
if (!rule || !rule.id || typeof rule.matches !== 'function') {
|
|
52
|
+
shared_1.logger.error({ filePath, rule }, 'invalid exported rule');
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
});
|
|
57
|
+
exports.CUSTOM_RULES.push(...exportedRules);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
shared_1.logger.error({ filePath, error }, 'error importing rule');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const ruleIds = [...exports.DEFAULT_RULES, ...exports.CUSTOM_RULES].map((rule) => rule.id);
|
|
64
|
+
const duplicatedRuleIds = ruleIds.filter((currentValue, currentIndex) => ruleIds.indexOf(currentValue) !== currentIndex);
|
|
65
|
+
if (duplicatedRuleIds.length > 0) {
|
|
66
|
+
shared_1.logger.error({ duplicatedRuleIds }, 'duplicated rule ids');
|
|
67
|
+
throw new Error('duplicated rule ids');
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
loadAdditionalRules();
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import LRUCache from 'lru-cache';
|
|
2
|
+
import type * as Stuntman from '@stuntman/shared';
|
|
3
|
+
export declare const getTrafficStore: (key: string, options?: Stuntman.StorageConfig) => LRUCache<string, Stuntman.LogEntry>;
|
|
4
|
+
export declare const getDnsResolutionCache: (key: string) => LRUCache<string, string>;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getDnsResolutionCache = exports.getTrafficStore = void 0;
|
|
7
|
+
const lru_cache_1 = __importDefault(require("lru-cache"));
|
|
8
|
+
const object_sizeof_1 = __importDefault(require("object-sizeof"));
|
|
9
|
+
const DNS_CACHE_OPTIONS = {
|
|
10
|
+
max: 1000,
|
|
11
|
+
ttl: 1000 * 60 * 15,
|
|
12
|
+
allowStale: false,
|
|
13
|
+
updateAgeOnGet: false,
|
|
14
|
+
updateAgeOnHas: false,
|
|
15
|
+
};
|
|
16
|
+
const trafficStoreInstances = {};
|
|
17
|
+
const dnsResolutionCacheInstances = {};
|
|
18
|
+
const getTrafficStore = (key, options) => {
|
|
19
|
+
if (!(key in trafficStoreInstances)) {
|
|
20
|
+
if (!options) {
|
|
21
|
+
throw new Error('initialize with options first');
|
|
22
|
+
}
|
|
23
|
+
trafficStoreInstances[key] = new lru_cache_1.default({
|
|
24
|
+
max: options.limitCount,
|
|
25
|
+
maxSize: options.limitSize,
|
|
26
|
+
ttl: options.ttl,
|
|
27
|
+
allowStale: false,
|
|
28
|
+
updateAgeOnGet: false,
|
|
29
|
+
updateAgeOnHas: false,
|
|
30
|
+
sizeCalculation: (value) => (0, object_sizeof_1.default)(value),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return trafficStoreInstances[key];
|
|
34
|
+
};
|
|
35
|
+
exports.getTrafficStore = getTrafficStore;
|
|
36
|
+
const getDnsResolutionCache = (key) => {
|
|
37
|
+
if (!(key in dnsResolutionCacheInstances)) {
|
|
38
|
+
dnsResolutionCacheInstances[key] = new lru_cache_1.default(DNS_CACHE_OPTIONS);
|
|
39
|
+
}
|
|
40
|
+
return dnsResolutionCacheInstances[key];
|
|
41
|
+
};
|
|
42
|
+
exports.getDnsResolutionCache = getDnsResolutionCache;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stuntman/server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Stuntman - HTTP proxy / mock server with API",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"repository": {
|
|
@@ -41,29 +41,32 @@
|
|
|
41
41
|
"pug": "3.0.2",
|
|
42
42
|
"serialize-javascript": "6.0.1",
|
|
43
43
|
"ts-import": "4.0.0-beta.10",
|
|
44
|
-
"typescript": "4.9.5",
|
|
45
44
|
"undici": "5.20.0",
|
|
46
45
|
"uuid": "9.0.0"
|
|
47
46
|
},
|
|
48
47
|
"devDependencies": {
|
|
48
|
+
"@jest/globals": "29.4.3",
|
|
49
49
|
"@prettier/plugin-pug": "2.4.1",
|
|
50
50
|
"@types/express": "4.17.17",
|
|
51
51
|
"@types/glob": "8.1.0",
|
|
52
52
|
"@types/serialize-javascript": "5.0.2",
|
|
53
53
|
"@types/uuid": "9.0.0",
|
|
54
|
-
"
|
|
54
|
+
"jest": "29.4.3",
|
|
55
|
+
"prettier": "2.8.4",
|
|
56
|
+
"typescript": "4.9.5"
|
|
55
57
|
},
|
|
56
58
|
"bin": {
|
|
57
59
|
"stuntman": "./dist/bin/stuntman.js"
|
|
58
60
|
},
|
|
59
61
|
"files": [
|
|
60
|
-
"
|
|
62
|
+
"src/**",
|
|
63
|
+
"dist/**",
|
|
61
64
|
"README.md",
|
|
62
65
|
"LICENSE",
|
|
63
66
|
"CHANGELOG.md"
|
|
64
67
|
],
|
|
65
68
|
"scripts": {
|
|
66
|
-
"test": "
|
|
69
|
+
"test": "SUPPRESS_NO_CONFIG_WARNING=1 jest",
|
|
67
70
|
"clean": "rm -fr dist",
|
|
68
71
|
"build": "tsc && cp -rv src/api/webgui dist/api",
|
|
69
72
|
"lint": "prettier --check . && eslint . --ext ts",
|
package/src/api/api.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
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: Stuntman.LogEntry[] = [];
|
|
107
|
+
for (const value of this.trafficStore.values()) {
|
|
108
|
+
serializedTraffic.push(value);
|
|
109
|
+
}
|
|
110
|
+
res.json(serializedTraffic);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
this.apiApp.get('/traffic/:ruleIdOrLabel', this.authReadOnly, (req, res) => {
|
|
114
|
+
const serializedTraffic: Stuntman.LogEntry[] = [];
|
|
115
|
+
for (const value of this.trafficStore.values()) {
|
|
116
|
+
if (value.mockRuleId === req.params.ruleIdOrLabel || (value.labels || []).includes(req.params.ruleIdOrLabel)) {
|
|
117
|
+
serializedTraffic.push(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
|
+
actions: rule.actions,
|
|
194
|
+
...(rule.disableAfterUse !== undefined && { disableAfterUse: rule.disableAfterUse }),
|
|
195
|
+
...(rule.isEnabled !== undefined && { isEnabled: rule.isEnabled }),
|
|
196
|
+
...(rule.labels !== undefined && { labels: rule.labels }),
|
|
197
|
+
...(rule.priority !== undefined && { priority: rule.priority }),
|
|
198
|
+
...(rule.removeAfterUse !== undefined && { removeAfterUse: rule.removeAfterUse }),
|
|
199
|
+
...(rule.storeTraffic !== undefined && { storeTraffic: rule.storeTraffic }),
|
|
200
|
+
},
|
|
201
|
+
true
|
|
202
|
+
);
|
|
203
|
+
res.send();
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
public start() {
|
|
208
|
+
if (this.server) {
|
|
209
|
+
throw new Error('mock server already started');
|
|
210
|
+
}
|
|
211
|
+
this.server = this.apiApp.listen(this.options.port, () => {
|
|
212
|
+
logger.info(`API listening on ${this.options.port}`);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
public stop() {
|
|
217
|
+
if (!this.server) {
|
|
218
|
+
throw new Error('mock server not started');
|
|
219
|
+
}
|
|
220
|
+
this.server.close((error) => {
|
|
221
|
+
logger.warn(error, 'problem closing server');
|
|
222
|
+
this.server = null;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|