faultmesh 1.0.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.
- package/README.md +98 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +105 -0
- package/dist/dashboard/app.js +998 -0
- package/dist/dashboard/index.html +291 -0
- package/dist/dashboard/styles.css +1120 -0
- package/dist/engine/ControlApi.d.ts +24 -0
- package/dist/engine/ControlApi.js +269 -0
- package/dist/engine/FaultMeshProxy.d.ts +16 -0
- package/dist/engine/FaultMeshProxy.js +167 -0
- package/dist/engine/TelemetryHub.d.ts +16 -0
- package/dist/engine/TelemetryHub.js +67 -0
- package/dist/engine/ToxicPipeline.d.ts +22 -0
- package/dist/engine/ToxicPipeline.js +67 -0
- package/dist/scorer/ResilienceScorer.d.ts +13 -0
- package/dist/scorer/ResilienceScorer.js +330 -0
- package/dist/scorer/SecurityAuditor.d.ts +13 -0
- package/dist/scorer/SecurityAuditor.js +428 -0
- package/dist/scorer/TrafficStormAuditor.d.ts +10 -0
- package/dist/scorer/TrafficStormAuditor.js +261 -0
- package/dist/server.d.ts +21 -0
- package/dist/server.js +169 -0
- package/dist/toxics/BandwidthToxic.d.ts +11 -0
- package/dist/toxics/BandwidthToxic.js +36 -0
- package/dist/toxics/BaseToxic.d.ts +11 -0
- package/dist/toxics/BaseToxic.js +19 -0
- package/dist/toxics/CorruptToxic.d.ts +11 -0
- package/dist/toxics/CorruptToxic.js +53 -0
- package/dist/toxics/CutToxic.d.ts +9 -0
- package/dist/toxics/CutToxic.js +31 -0
- package/dist/toxics/LatencyToxic.d.ts +13 -0
- package/dist/toxics/LatencyToxic.js +40 -0
- package/dist/toxics/StatusToxic.d.ts +9 -0
- package/dist/toxics/StatusToxic.js +22 -0
- package/dist/types.d.ts +123 -0
- package/dist/types.js +4 -0
- package/package.json +35 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ToxicPipeline } from './ToxicPipeline.js';
|
|
2
|
+
import { TelemetryHub } from './TelemetryHub.js';
|
|
3
|
+
import { ResilienceScorer } from '../scorer/ResilienceScorer.js';
|
|
4
|
+
import { SecurityAuditor } from '../scorer/SecurityAuditor.js';
|
|
5
|
+
import { TrafficStormAuditor } from '../scorer/TrafficStormAuditor.js';
|
|
6
|
+
export declare class ControlApi {
|
|
7
|
+
private server;
|
|
8
|
+
private port;
|
|
9
|
+
private pipeline;
|
|
10
|
+
private telemetryHub;
|
|
11
|
+
private scorer;
|
|
12
|
+
private securityAuditor;
|
|
13
|
+
private trafficStormAuditor;
|
|
14
|
+
private dashboardDir;
|
|
15
|
+
private isRunning;
|
|
16
|
+
constructor(port: number, pipeline: ToxicPipeline, telemetryHub: TelemetryHub, scorer: ResilienceScorer, securityAuditor?: SecurityAuditor, trafficStormAuditor?: TrafficStormAuditor, dashboardDir?: string);
|
|
17
|
+
private handleRequest;
|
|
18
|
+
private serveStatic;
|
|
19
|
+
private readBody;
|
|
20
|
+
private json;
|
|
21
|
+
start(): Promise<void>;
|
|
22
|
+
stop(): Promise<void>;
|
|
23
|
+
getPort(): number;
|
|
24
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { SecurityAuditor } from '../scorer/SecurityAuditor.js';
|
|
6
|
+
import { TrafficStormAuditor } from '../scorer/TrafficStormAuditor.js';
|
|
7
|
+
export class ControlApi {
|
|
8
|
+
server;
|
|
9
|
+
port = 0;
|
|
10
|
+
pipeline;
|
|
11
|
+
telemetryHub;
|
|
12
|
+
scorer;
|
|
13
|
+
securityAuditor;
|
|
14
|
+
trafficStormAuditor;
|
|
15
|
+
dashboardDir;
|
|
16
|
+
isRunning = false;
|
|
17
|
+
constructor(port, pipeline, telemetryHub, scorer, securityAuditor, trafficStormAuditor, dashboardDir) {
|
|
18
|
+
this.port = port;
|
|
19
|
+
this.pipeline = pipeline;
|
|
20
|
+
this.telemetryHub = telemetryHub;
|
|
21
|
+
this.scorer = scorer;
|
|
22
|
+
this.securityAuditor = securityAuditor || new SecurityAuditor('http://127.0.0.1:3001');
|
|
23
|
+
this.trafficStormAuditor = trafficStormAuditor || new TrafficStormAuditor('http://127.0.0.1:3001');
|
|
24
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
let resolvedDir = dashboardDir || path.resolve(currentDir, '../dashboard');
|
|
26
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
27
|
+
const fallbackDir = path.resolve(currentDir, '../../src/dashboard');
|
|
28
|
+
if (fs.existsSync(fallbackDir)) {
|
|
29
|
+
resolvedDir = fallbackDir;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
this.dashboardDir = resolvedDir;
|
|
33
|
+
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
34
|
+
}
|
|
35
|
+
async handleRequest(req, res) {
|
|
36
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
37
|
+
const pathname = url.pathname;
|
|
38
|
+
// Enable CORS for local dashboards or external runners
|
|
39
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
40
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
41
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
42
|
+
if (req.method === 'OPTIONS') {
|
|
43
|
+
res.writeHead(204);
|
|
44
|
+
res.end();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
// 1. SSE Stream
|
|
48
|
+
if (pathname === '/_faultmesh/telemetry/stream' && req.method === 'GET') {
|
|
49
|
+
res.writeHead(200, {
|
|
50
|
+
'Content-Type': 'text/event-stream',
|
|
51
|
+
'Cache-Control': 'no-cache',
|
|
52
|
+
'Connection': 'keep-alive',
|
|
53
|
+
});
|
|
54
|
+
this.telemetryHub.registerSSEClient(res);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// 2. Status & Metrics
|
|
58
|
+
if (pathname === '/_faultmesh/status' && req.method === 'GET') {
|
|
59
|
+
const rules = this.pipeline.getRules();
|
|
60
|
+
this.json(res, 200, {
|
|
61
|
+
status: 'online',
|
|
62
|
+
engine: 'FaultMesh v1.0.0',
|
|
63
|
+
metrics: this.telemetryHub.getMetrics(),
|
|
64
|
+
activeRules: rules,
|
|
65
|
+
activeToxics: rules, // backward compatibility
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// 3. Rules List
|
|
70
|
+
if ((pathname === '/_faultmesh/rules' || pathname === '/_faultmesh/toxics') && req.method === 'GET') {
|
|
71
|
+
this.json(res, 200, this.pipeline.getRules());
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// 4. Add / Update Rule
|
|
75
|
+
if ((pathname === '/_faultmesh/rules' || pathname === '/_faultmesh/toxics') && req.method === 'POST') {
|
|
76
|
+
const body = await this.readBody(req);
|
|
77
|
+
try {
|
|
78
|
+
const rule = JSON.parse(body);
|
|
79
|
+
if (!rule.id || !rule.type || !rule.config) {
|
|
80
|
+
this.json(res, 400, { error: 'Invalid rule definition. Required: id, type, config' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.pipeline.addRule(rule);
|
|
84
|
+
this.json(res, 201, { success: true, rule });
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
this.json(res, 400, { error: 'Malformed JSON payload', details: err.message });
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// 5. Delete Rule
|
|
92
|
+
const isDeleteSpecific = pathname.startsWith('/_faultmesh/rules/') || pathname.startsWith('/_faultmesh/toxics/');
|
|
93
|
+
if (isDeleteSpecific && req.method === 'DELETE') {
|
|
94
|
+
const prefix = pathname.startsWith('/_faultmesh/rules/') ? '/_faultmesh/rules/' : '/_faultmesh/toxics/';
|
|
95
|
+
const id = pathname.substring(prefix.length);
|
|
96
|
+
const removed = this.pipeline.removeRule(id);
|
|
97
|
+
this.json(res, removed ? 200 : 404, { success: removed, id });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if ((pathname === '/_faultmesh/rules' || pathname === '/_faultmesh/toxics') && req.method === 'DELETE') {
|
|
101
|
+
this.pipeline.clearRules();
|
|
102
|
+
this.json(res, 200, { success: true, message: 'All simulation rules cleared' });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// 6. Run Diagnostics / Health Check
|
|
106
|
+
const isRunTests = pathname === '/_faultmesh/diagnostics/run' || pathname === '/_faultmesh/tests/run' || pathname === '/_faultmesh/gauntlet/run';
|
|
107
|
+
if (isRunTests && req.method === 'POST') {
|
|
108
|
+
try {
|
|
109
|
+
let profile = 'resilient';
|
|
110
|
+
const queryProfile = url.searchParams.get('profile');
|
|
111
|
+
if (queryProfile === 'fragile' || queryProfile === 'resilient') {
|
|
112
|
+
profile = queryProfile;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const body = await this.readBody(req);
|
|
116
|
+
if (body) {
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(body);
|
|
119
|
+
if (parsed.profile === 'fragile' || parsed.profile === 'resilient') {
|
|
120
|
+
profile = parsed.profile;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch { }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const scorecard = await this.scorer.runGauntlet(profile);
|
|
127
|
+
this.json(res, 200, scorecard);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
this.json(res, 500, { error: 'Diagnostics execution error', details: err.message });
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// 7. Run Security & Protocol Audit
|
|
135
|
+
const isRunSecurity = pathname === '/_faultmesh/security/run' || pathname === '/_faultmesh/security/audit';
|
|
136
|
+
if (isRunSecurity && req.method === 'POST') {
|
|
137
|
+
try {
|
|
138
|
+
let profile = 'secure';
|
|
139
|
+
const queryProfile = url.searchParams.get('profile');
|
|
140
|
+
if (queryProfile === 'secure' || queryProfile === 'vulnerable') {
|
|
141
|
+
profile = queryProfile;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
const body = await this.readBody(req);
|
|
145
|
+
if (body) {
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(body);
|
|
148
|
+
if (parsed.profile === 'secure' || parsed.profile === 'vulnerable') {
|
|
149
|
+
profile = parsed.profile;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch { }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const scorecard = await this.securityAuditor.runAudit(profile);
|
|
156
|
+
this.json(res, 200, scorecard);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
this.json(res, 500, { error: 'Security audit execution error', details: err.message });
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// 8. Run Traffic Storm & DoS Resilience Audit
|
|
164
|
+
const isRunStorm = pathname === '/_faultmesh/storm/run' || pathname === '/_faultmesh/storm/audit';
|
|
165
|
+
if (isRunStorm && req.method === 'POST') {
|
|
166
|
+
try {
|
|
167
|
+
let profile = 'resilient';
|
|
168
|
+
const queryProfile = url.searchParams.get('profile');
|
|
169
|
+
if (queryProfile === 'fragile' || queryProfile === 'resilient') {
|
|
170
|
+
profile = queryProfile;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const body = await this.readBody(req);
|
|
174
|
+
if (body) {
|
|
175
|
+
try {
|
|
176
|
+
const parsed = JSON.parse(body);
|
|
177
|
+
if (parsed.profile === 'fragile' || parsed.profile === 'resilient') {
|
|
178
|
+
profile = parsed.profile;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch { }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const scorecard = await this.trafficStormAuditor.runStormSuite(profile);
|
|
185
|
+
this.json(res, 200, scorecard);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
this.json(res, 500, { error: 'Traffic storm execution error', details: err.message });
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// 9. Static Dashboard Serving
|
|
193
|
+
if (req.method === 'GET') {
|
|
194
|
+
this.serveStatic(pathname, res);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.json(res, 404, { error: 'Route not found' });
|
|
198
|
+
}
|
|
199
|
+
serveStatic(pathname, res) {
|
|
200
|
+
const safePath = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
|
|
201
|
+
const filePath = path.join(this.dashboardDir, safePath);
|
|
202
|
+
// Prevent directory traversal
|
|
203
|
+
if (!filePath.startsWith(this.dashboardDir)) {
|
|
204
|
+
this.json(res, 403, { error: 'Forbidden' });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
208
|
+
const ext = path.extname(filePath);
|
|
209
|
+
const contentTypes = {
|
|
210
|
+
'.html': 'text/html',
|
|
211
|
+
'.css': 'text/css',
|
|
212
|
+
'.js': 'application/javascript',
|
|
213
|
+
'.json': 'application/json',
|
|
214
|
+
'.svg': 'image/svg+xml',
|
|
215
|
+
};
|
|
216
|
+
res.writeHead(200, { 'Content-Type': contentTypes[ext] || 'text/plain' });
|
|
217
|
+
fs.createReadStream(filePath).pipe(res);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
// Fallback to index.html for SPA if exists
|
|
221
|
+
const indexPath = path.join(this.dashboardDir, 'index.html');
|
|
222
|
+
if (fs.existsSync(indexPath)) {
|
|
223
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
224
|
+
fs.createReadStream(indexPath).pipe(res);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
this.json(res, 404, { error: 'File not found' });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
readBody(req) {
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
let data = '';
|
|
234
|
+
req.on('data', chunk => data += chunk);
|
|
235
|
+
req.on('end', () => resolve(data));
|
|
236
|
+
req.on('error', reject);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
json(res, status, data) {
|
|
240
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
241
|
+
res.end(JSON.stringify(data));
|
|
242
|
+
}
|
|
243
|
+
start() {
|
|
244
|
+
return new Promise((resolve, reject) => {
|
|
245
|
+
this.server.listen(this.port, () => {
|
|
246
|
+
const address = this.server.address();
|
|
247
|
+
if (address && typeof address === 'object') {
|
|
248
|
+
this.port = address.port;
|
|
249
|
+
}
|
|
250
|
+
this.isRunning = true;
|
|
251
|
+
resolve();
|
|
252
|
+
});
|
|
253
|
+
this.server.once('error', reject);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
stop() {
|
|
257
|
+
return new Promise((resolve) => {
|
|
258
|
+
if (!this.isRunning)
|
|
259
|
+
return resolve();
|
|
260
|
+
this.server.close(() => {
|
|
261
|
+
this.isRunning = false;
|
|
262
|
+
resolve();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
getPort() {
|
|
267
|
+
return this.port;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ProxyConfig } from '../types.js';
|
|
2
|
+
import { ToxicPipeline } from './ToxicPipeline.js';
|
|
3
|
+
import { TelemetryHub } from './TelemetryHub.js';
|
|
4
|
+
export declare class FaultMeshProxy {
|
|
5
|
+
private server;
|
|
6
|
+
private port;
|
|
7
|
+
private config;
|
|
8
|
+
private toxicPipeline;
|
|
9
|
+
private telemetryHub;
|
|
10
|
+
private isRunning;
|
|
11
|
+
constructor(config: ProxyConfig, toxicPipeline: ToxicPipeline, telemetryHub: TelemetryHub);
|
|
12
|
+
private handleRequest;
|
|
13
|
+
start(): Promise<void>;
|
|
14
|
+
stop(): Promise<void>;
|
|
15
|
+
getPort(): number;
|
|
16
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
export class FaultMeshProxy {
|
|
3
|
+
server;
|
|
4
|
+
port = 0;
|
|
5
|
+
config;
|
|
6
|
+
toxicPipeline;
|
|
7
|
+
telemetryHub;
|
|
8
|
+
isRunning = false;
|
|
9
|
+
constructor(config, toxicPipeline, telemetryHub) {
|
|
10
|
+
this.config = config;
|
|
11
|
+
this.toxicPipeline = toxicPipeline;
|
|
12
|
+
this.telemetryHub = telemetryHub;
|
|
13
|
+
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
14
|
+
}
|
|
15
|
+
async handleRequest(req, res) {
|
|
16
|
+
const startTime = Date.now();
|
|
17
|
+
const requestId = `req_${Math.random().toString(36).substring(2, 9)}`;
|
|
18
|
+
this.telemetryHub.recordRequestStart();
|
|
19
|
+
const appliedToxics = [];
|
|
20
|
+
let bytesReceived = 0;
|
|
21
|
+
let bytesSent = 0;
|
|
22
|
+
if (req.method === 'OPTIONS') {
|
|
23
|
+
res.writeHead(204, {
|
|
24
|
+
'Access-Control-Allow-Origin': '*',
|
|
25
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
|
26
|
+
'Access-Control-Allow-Headers': '*',
|
|
27
|
+
});
|
|
28
|
+
res.end();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// 1. Check for immediate downstream Status override toxic
|
|
32
|
+
const statusToxic = this.toxicPipeline.getActiveStatusToxic('downstream');
|
|
33
|
+
if (statusToxic) {
|
|
34
|
+
appliedToxics.push(`Status (${statusToxic.statusCode})`);
|
|
35
|
+
const body = statusToxic.responseBody || JSON.stringify({
|
|
36
|
+
error: statusToxic.statusMessage,
|
|
37
|
+
code: statusToxic.statusCode,
|
|
38
|
+
faultMesh: true,
|
|
39
|
+
});
|
|
40
|
+
res.writeHead(statusToxic.statusCode, {
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
'X-FaultMesh-Injected': 'status',
|
|
43
|
+
'Access-Control-Allow-Origin': '*',
|
|
44
|
+
});
|
|
45
|
+
res.end(body);
|
|
46
|
+
this.telemetryHub.recordRequestComplete({
|
|
47
|
+
id: requestId,
|
|
48
|
+
timestamp: startTime,
|
|
49
|
+
method: req.method || 'GET',
|
|
50
|
+
path: req.url || '/',
|
|
51
|
+
statusCode: statusToxic.statusCode,
|
|
52
|
+
durationMs: Date.now() - startTime,
|
|
53
|
+
bytesReceived: 0,
|
|
54
|
+
bytesSent: Buffer.byteLength(body),
|
|
55
|
+
appliedToxics,
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// 2. Prepare Upstream forwarding
|
|
60
|
+
const targetUrl = new URL(req.url || '/', this.config.targetUrl);
|
|
61
|
+
const options = {
|
|
62
|
+
protocol: targetUrl.protocol,
|
|
63
|
+
hostname: targetUrl.hostname,
|
|
64
|
+
port: targetUrl.port,
|
|
65
|
+
path: targetUrl.pathname + targetUrl.search,
|
|
66
|
+
method: req.method,
|
|
67
|
+
headers: { ...req.headers, host: targetUrl.host },
|
|
68
|
+
};
|
|
69
|
+
const upstreamReq = http.request(options, async (upstreamRes) => {
|
|
70
|
+
// 3. Prepare Downstream transformers
|
|
71
|
+
const { transformers: downstreamTransformers, appliedNames } = this.toxicPipeline.createStreamTransformers('downstream');
|
|
72
|
+
appliedToxics.push(...appliedNames);
|
|
73
|
+
const headers = { ...upstreamRes.headers };
|
|
74
|
+
delete headers['content-length']; // Length changes if chunks are compressed, cut, or padded
|
|
75
|
+
headers['x-faultmesh-proxy'] = 'active';
|
|
76
|
+
headers['access-control-allow-origin'] = '*';
|
|
77
|
+
res.writeHead(upstreamRes.statusCode || 200, headers);
|
|
78
|
+
try {
|
|
79
|
+
if (downstreamTransformers.length > 0) {
|
|
80
|
+
// Chain transformers between upstreamRes and client res
|
|
81
|
+
let currentStream = upstreamRes;
|
|
82
|
+
for (const t of downstreamTransformers) {
|
|
83
|
+
currentStream = currentStream.pipe(t);
|
|
84
|
+
}
|
|
85
|
+
currentStream.on('data', (chunk) => {
|
|
86
|
+
bytesSent += chunk.length;
|
|
87
|
+
res.write(chunk);
|
|
88
|
+
});
|
|
89
|
+
currentStream.on('end', () => {
|
|
90
|
+
res.end();
|
|
91
|
+
finishTelemetry(upstreamRes.statusCode || 200);
|
|
92
|
+
});
|
|
93
|
+
currentStream.on('error', (err) => {
|
|
94
|
+
res.destroy(err);
|
|
95
|
+
finishTelemetry(upstreamRes.statusCode || 200, err.message);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
upstreamRes.on('data', (chunk) => {
|
|
100
|
+
bytesSent += chunk.length;
|
|
101
|
+
res.write(chunk);
|
|
102
|
+
});
|
|
103
|
+
upstreamRes.on('end', () => {
|
|
104
|
+
res.end();
|
|
105
|
+
finishTelemetry(upstreamRes.statusCode || 200);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
res.destroy(err);
|
|
111
|
+
finishTelemetry(502, err.message);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
const finishTelemetry = (statusCode, error) => {
|
|
115
|
+
this.telemetryHub.recordRequestComplete({
|
|
116
|
+
id: requestId,
|
|
117
|
+
timestamp: startTime,
|
|
118
|
+
method: req.method || 'GET',
|
|
119
|
+
path: req.url || '/',
|
|
120
|
+
statusCode,
|
|
121
|
+
durationMs: Date.now() - startTime,
|
|
122
|
+
bytesReceived,
|
|
123
|
+
bytesSent,
|
|
124
|
+
appliedToxics,
|
|
125
|
+
error,
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
upstreamReq.on('error', (err) => {
|
|
129
|
+
if (!res.headersSent) {
|
|
130
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
131
|
+
res.end(JSON.stringify({ error: 'Upstream connection failed', details: err.message }));
|
|
132
|
+
}
|
|
133
|
+
finishTelemetry(502, err.message);
|
|
134
|
+
});
|
|
135
|
+
req.on('data', (chunk) => {
|
|
136
|
+
bytesReceived += chunk.length;
|
|
137
|
+
});
|
|
138
|
+
// Forward request body to upstream
|
|
139
|
+
req.pipe(upstreamReq);
|
|
140
|
+
}
|
|
141
|
+
start() {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
this.server.listen(this.config.port, () => {
|
|
144
|
+
const address = this.server.address();
|
|
145
|
+
if (address && typeof address === 'object') {
|
|
146
|
+
this.port = address.port;
|
|
147
|
+
}
|
|
148
|
+
this.isRunning = true;
|
|
149
|
+
resolve();
|
|
150
|
+
});
|
|
151
|
+
this.server.once('error', reject);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
stop() {
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
if (!this.isRunning)
|
|
157
|
+
return resolve();
|
|
158
|
+
this.server.close(() => {
|
|
159
|
+
this.isRunning = false;
|
|
160
|
+
resolve();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
getPort() {
|
|
165
|
+
return this.port;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ServerResponse } from 'node:http';
|
|
2
|
+
import { ProxyMetrics, TelemetryEvent } from '../types.js';
|
|
3
|
+
export declare class TelemetryHub {
|
|
4
|
+
private metrics;
|
|
5
|
+
private recentEvents;
|
|
6
|
+
private readonly maxRecentEvents;
|
|
7
|
+
private sseClients;
|
|
8
|
+
private latencySum;
|
|
9
|
+
recordRequestStart(): void;
|
|
10
|
+
recordRequestComplete(event: TelemetryEvent): void;
|
|
11
|
+
getMetrics(): ProxyMetrics;
|
|
12
|
+
getRecentEvents(): TelemetryEvent[];
|
|
13
|
+
registerSSEClient(res: ServerResponse): void;
|
|
14
|
+
private broadcastSSE;
|
|
15
|
+
clear(): void;
|
|
16
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export class TelemetryHub {
|
|
2
|
+
metrics = {
|
|
3
|
+
totalRequests: 0,
|
|
4
|
+
activeConnections: 0,
|
|
5
|
+
bytesProxied: 0,
|
|
6
|
+
faultsInjected: 0,
|
|
7
|
+
avgLatencyMs: 0,
|
|
8
|
+
};
|
|
9
|
+
recentEvents = [];
|
|
10
|
+
maxRecentEvents = 50;
|
|
11
|
+
sseClients = new Set();
|
|
12
|
+
latencySum = 0;
|
|
13
|
+
recordRequestStart() {
|
|
14
|
+
this.metrics.totalRequests++;
|
|
15
|
+
this.metrics.activeConnections++;
|
|
16
|
+
}
|
|
17
|
+
recordRequestComplete(event) {
|
|
18
|
+
this.metrics.activeConnections = Math.max(0, this.metrics.activeConnections - 1);
|
|
19
|
+
this.metrics.bytesProxied += event.bytesReceived + event.bytesSent;
|
|
20
|
+
this.metrics.faultsInjected += event.appliedToxics.length;
|
|
21
|
+
this.latencySum += event.durationMs;
|
|
22
|
+
this.metrics.avgLatencyMs = Math.round(this.latencySum / this.metrics.totalRequests);
|
|
23
|
+
// Keep ring buffer
|
|
24
|
+
this.recentEvents.unshift(event);
|
|
25
|
+
if (this.recentEvents.length > this.maxRecentEvents) {
|
|
26
|
+
this.recentEvents.pop();
|
|
27
|
+
}
|
|
28
|
+
// Broadcast to live SSE subscribers
|
|
29
|
+
this.broadcastSSE('telemetry', event);
|
|
30
|
+
}
|
|
31
|
+
getMetrics() {
|
|
32
|
+
return { ...this.metrics };
|
|
33
|
+
}
|
|
34
|
+
getRecentEvents() {
|
|
35
|
+
return [...this.recentEvents];
|
|
36
|
+
}
|
|
37
|
+
registerSSEClient(res) {
|
|
38
|
+
this.sseClients.add(res);
|
|
39
|
+
// Send initial snapshot
|
|
40
|
+
res.write(`event: snapshot\ndata: ${JSON.stringify({ metrics: this.metrics, events: this.recentEvents })}\n\n`);
|
|
41
|
+
res.on('close', () => {
|
|
42
|
+
this.sseClients.delete(res);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
broadcastSSE(eventType, data) {
|
|
46
|
+
const payload = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
47
|
+
for (const client of this.sseClients) {
|
|
48
|
+
try {
|
|
49
|
+
client.write(payload);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
this.sseClients.delete(client);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
clear() {
|
|
57
|
+
this.metrics = {
|
|
58
|
+
totalRequests: 0,
|
|
59
|
+
activeConnections: 0,
|
|
60
|
+
bytesProxied: 0,
|
|
61
|
+
faultsInjected: 0,
|
|
62
|
+
avgLatencyMs: 0,
|
|
63
|
+
};
|
|
64
|
+
this.latencySum = 0;
|
|
65
|
+
this.recentEvents = [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Transform } from 'node:stream';
|
|
2
|
+
import { ToxicRule, ToxicStreamDirection } from '../types.js';
|
|
3
|
+
import { StatusToxic } from '../toxics/StatusToxic.js';
|
|
4
|
+
export declare class ToxicPipeline {
|
|
5
|
+
private rules;
|
|
6
|
+
addRule(rule: ToxicRule): void;
|
|
7
|
+
removeRule(id: string): boolean;
|
|
8
|
+
getRules(): ToxicRule[];
|
|
9
|
+
getRule(id: string): ToxicRule | undefined;
|
|
10
|
+
clearRules(): void;
|
|
11
|
+
/**
|
|
12
|
+
* Check if any active StatusToxic exists for the specified direction
|
|
13
|
+
*/
|
|
14
|
+
getActiveStatusToxic(direction: ToxicStreamDirection): StatusToxic | null;
|
|
15
|
+
/**
|
|
16
|
+
* Create an array of streaming transformers for the specified stream direction
|
|
17
|
+
*/
|
|
18
|
+
createStreamTransformers(direction: ToxicStreamDirection): {
|
|
19
|
+
transformers: Transform[];
|
|
20
|
+
appliedNames: string[];
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { LatencyToxic } from '../toxics/LatencyToxic.js';
|
|
2
|
+
import { BandwidthToxic } from '../toxics/BandwidthToxic.js';
|
|
3
|
+
import { CutToxic } from '../toxics/CutToxic.js';
|
|
4
|
+
import { CorruptToxic } from '../toxics/CorruptToxic.js';
|
|
5
|
+
import { StatusToxic } from '../toxics/StatusToxic.js';
|
|
6
|
+
export class ToxicPipeline {
|
|
7
|
+
rules = new Map();
|
|
8
|
+
addRule(rule) {
|
|
9
|
+
this.rules.set(rule.id, rule);
|
|
10
|
+
}
|
|
11
|
+
removeRule(id) {
|
|
12
|
+
return this.rules.delete(id);
|
|
13
|
+
}
|
|
14
|
+
getRules() {
|
|
15
|
+
return Array.from(this.rules.values());
|
|
16
|
+
}
|
|
17
|
+
getRule(id) {
|
|
18
|
+
return this.rules.get(id);
|
|
19
|
+
}
|
|
20
|
+
clearRules() {
|
|
21
|
+
this.rules.clear();
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Check if any active StatusToxic exists for the specified direction
|
|
25
|
+
*/
|
|
26
|
+
getActiveStatusToxic(direction) {
|
|
27
|
+
for (const rule of this.rules.values()) {
|
|
28
|
+
if (rule.enabled && rule.type === 'status' && rule.direction === direction) {
|
|
29
|
+
return new StatusToxic(rule.config);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Create an array of streaming transformers for the specified stream direction
|
|
36
|
+
*/
|
|
37
|
+
createStreamTransformers(direction) {
|
|
38
|
+
const transformers = [];
|
|
39
|
+
const appliedNames = [];
|
|
40
|
+
for (const rule of this.rules.values()) {
|
|
41
|
+
if (!rule.enabled || rule.direction !== direction)
|
|
42
|
+
continue;
|
|
43
|
+
switch (rule.type) {
|
|
44
|
+
case 'latency':
|
|
45
|
+
transformers.push(new LatencyToxic(rule.config));
|
|
46
|
+
appliedNames.push(`Latency (${rule.name})`);
|
|
47
|
+
break;
|
|
48
|
+
case 'bandwidth':
|
|
49
|
+
transformers.push(new BandwidthToxic(rule.config));
|
|
50
|
+
appliedNames.push(`Bandwidth (${rule.name})`);
|
|
51
|
+
break;
|
|
52
|
+
case 'cut':
|
|
53
|
+
transformers.push(new CutToxic(rule.config));
|
|
54
|
+
appliedNames.push(`Cut (${rule.name})`);
|
|
55
|
+
break;
|
|
56
|
+
case 'corrupt':
|
|
57
|
+
transformers.push(new CorruptToxic(rule.config));
|
|
58
|
+
appliedNames.push(`Corrupt (${rule.name})`);
|
|
59
|
+
break;
|
|
60
|
+
case 'status':
|
|
61
|
+
// Status toxics are handled at the HTTP response header layer, not stream byte level
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { transformers, appliedNames };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ResilienceScorecard } from '../types.js';
|
|
2
|
+
import { ToxicPipeline } from '../engine/ToxicPipeline.js';
|
|
3
|
+
export declare class ResilienceScorer {
|
|
4
|
+
private proxyUrl;
|
|
5
|
+
private pipeline;
|
|
6
|
+
constructor(proxyUrl: string, pipeline: ToxicPipeline);
|
|
7
|
+
runGauntlet(profile?: 'resilient' | 'fragile'): Promise<ResilienceScorecard>;
|
|
8
|
+
private testLatencySpike;
|
|
9
|
+
private testBandwidthConstraint;
|
|
10
|
+
private testConnectionCut;
|
|
11
|
+
private testPayloadCorruption;
|
|
12
|
+
private testServiceOutage;
|
|
13
|
+
}
|