bs9 1.0.0 → 1.3.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.
@@ -0,0 +1,481 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { serve } from "bun";
4
+ import { setTimeout } from "node:timers/promises";
5
+
6
+ // Security: Input validation functions
7
+ function isValidHost(host: string): boolean {
8
+ const hostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/;
9
+ const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
10
+ const localhostRegex = /^(localhost|127\.0\.0\.1)$/;
11
+
12
+ return hostnameRegex.test(host) && host.length <= 253 ||
13
+ ipv4Regex.test(host) ||
14
+ localhostRegex.test(host);
15
+ }
16
+
17
+ function isValidPort(port: number): boolean {
18
+ return !isNaN(port) && port >= 1 && port <= 65535;
19
+ }
20
+
21
+ function isValidPath(path: string): boolean {
22
+ // Prevent path traversal attacks
23
+ return !path.includes('..') && !path.includes('~') &&
24
+ /^[a-zA-Z0-9\-_\/]*$/.test(path) && path.length <= 256;
25
+ }
26
+
27
+ function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
28
+ const sanitized: Record<string, string> = {};
29
+ const allowedHeaders = [
30
+ 'content-type', 'content-length', 'accept', 'accept-encoding',
31
+ 'accept-language', 'user-agent', 'authorization', 'x-forwarded-for',
32
+ 'x-real-ip', 'x-forwarded-proto', 'host', 'connection'
33
+ ];
34
+
35
+ for (const [key, value] of Object.entries(headers)) {
36
+ const lowerKey = key.toLowerCase();
37
+ if (allowedHeaders.includes(lowerKey)) {
38
+ // Remove potential injection attempts
39
+ sanitized[key] = value.replace(/[\r\n]/g, '').substring(0, 1024);
40
+ }
41
+ }
42
+
43
+ return sanitized;
44
+ }
45
+
46
+ interface LoadBalancerConfig {
47
+ port: number;
48
+ algorithm: 'round-robin' | 'least-connections' | 'weighted-round-robin';
49
+ healthCheck: {
50
+ enabled: boolean;
51
+ path: string;
52
+ interval: number;
53
+ timeout: number;
54
+ retries: number;
55
+ };
56
+ backends: BackendServer[];
57
+ }
58
+
59
+ interface BackendServer {
60
+ id: string;
61
+ host: string;
62
+ port: number;
63
+ weight?: number;
64
+ connections: number;
65
+ healthy: boolean;
66
+ lastHealthCheck: number;
67
+ responseTime: number;
68
+ }
69
+
70
+ interface LoadBalancerStats {
71
+ totalRequests: number;
72
+ activeConnections: number;
73
+ backendStats: Array<{
74
+ id: string;
75
+ host: string;
76
+ port: number;
77
+ connections: number;
78
+ healthy: boolean;
79
+ responseTime: number;
80
+ requestsHandled: number;
81
+ }>;
82
+ }
83
+
84
+ class LoadBalancer {
85
+ private config: LoadBalancerConfig;
86
+ private currentIndex = 0;
87
+ private stats: LoadBalancerStats = {
88
+ totalRequests: 0,
89
+ activeConnections: 0,
90
+ backendStats: [],
91
+ };
92
+
93
+ constructor(config: LoadBalancerConfig) {
94
+ // Security: Validate configuration
95
+ if (!isValidPort(config.port)) {
96
+ throw new Error(`❌ Security: Invalid load balancer port: ${config.port}`);
97
+ }
98
+
99
+ if (!isValidPath(config.healthCheck.path)) {
100
+ throw new Error(`❌ Security: Invalid health check path: ${config.healthCheck.path}`);
101
+ }
102
+
103
+ // Security: Validate backends
104
+ for (const backend of config.backends) {
105
+ if (!isValidHost(backend.host)) {
106
+ throw new Error(`❌ Security: Invalid backend host: ${backend.host}`);
107
+ }
108
+
109
+ if (!isValidPort(backend.port)) {
110
+ throw new Error(`❌ Security: Invalid backend port: ${backend.port}`);
111
+ }
112
+
113
+ if (!/^[a-zA-Z0-9_-]+$/.test(backend.id) || backend.id.length > 64) {
114
+ throw new Error(`❌ Security: Invalid backend ID: ${backend.id}`);
115
+ }
116
+ }
117
+
118
+ this.config = config;
119
+ this.initializeStats();
120
+
121
+ // Start health checking
122
+ if (config.healthCheck.enabled) {
123
+ this.startHealthChecking();
124
+ }
125
+ }
126
+
127
+ private initializeStats(): void {
128
+ this.stats.backendStats = this.config.backends.map(backend => ({
129
+ id: backend.id,
130
+ host: backend.host,
131
+ port: backend.port,
132
+ connections: 0,
133
+ healthy: true,
134
+ responseTime: 0,
135
+ requestsHandled: 0,
136
+ }));
137
+ }
138
+
139
+ private startHealthChecking(): void {
140
+ setInterval(async () => {
141
+ await this.performHealthChecks();
142
+ }, this.config.healthCheck.interval);
143
+ }
144
+
145
+ private async performHealthChecks(): Promise<void> {
146
+ for (const backend of this.config.backends) {
147
+ try {
148
+ const startTime = Date.now();
149
+ const response = await fetch(`http://${backend.host}:${backend.port}${this.config.healthCheck.path}`, {
150
+ method: 'GET',
151
+ signal: AbortSignal.timeout(this.config.healthCheck.timeout),
152
+ });
153
+
154
+ const responseTime = Date.now() - startTime;
155
+
156
+ if (response.ok) {
157
+ backend.healthy = true;
158
+ backend.responseTime = responseTime;
159
+ } else {
160
+ backend.healthy = false;
161
+ }
162
+
163
+ backend.lastHealthCheck = Date.now();
164
+
165
+ } catch (error) {
166
+ backend.healthy = false;
167
+ backend.lastHealthCheck = Date.now();
168
+ }
169
+ }
170
+ }
171
+
172
+ private selectBackend(): BackendServer | null {
173
+ const healthyBackends = this.config.backends.filter(b => b.healthy);
174
+
175
+ if (healthyBackends.length === 0) {
176
+ return null;
177
+ }
178
+
179
+ switch (this.config.algorithm) {
180
+ case 'round-robin':
181
+ return this.selectRoundRobin(healthyBackends);
182
+ case 'least-connections':
183
+ return this.selectLeastConnections(healthyBackends);
184
+ case 'weighted-round-robin':
185
+ return this.selectWeightedRoundRobin(healthyBackends);
186
+ default:
187
+ return healthyBackends[0];
188
+ }
189
+ }
190
+
191
+ private selectRoundRobin(backends: BackendServer[]): BackendServer {
192
+ const backend = backends[this.currentIndex % backends.length];
193
+ this.currentIndex++;
194
+ return backend;
195
+ }
196
+
197
+ private selectLeastConnections(backends: BackendServer[]): BackendServer {
198
+ return backends.reduce((min, current) =>
199
+ current.connections < min.connections ? current : min
200
+ );
201
+ }
202
+
203
+ private selectWeightedRoundRobin(backends: BackendServer[]): BackendServer {
204
+ const totalWeight = backends.reduce((sum, b) => sum + (b.weight || 1), 0);
205
+ let random = Math.random() * totalWeight;
206
+
207
+ for (const backend of backends) {
208
+ random -= (backend.weight || 1);
209
+ if (random <= 0) {
210
+ return backend;
211
+ }
212
+ }
213
+
214
+ return backends[0];
215
+ }
216
+
217
+ public async handleRequest(request: Request): Promise<Response> {
218
+ const backend = this.selectBackend();
219
+
220
+ if (!backend) {
221
+ return new Response('Service Unavailable - No healthy backends', {
222
+ status: 503,
223
+ headers: { 'Retry-After': '5' }
224
+ });
225
+ }
226
+
227
+ // Update stats
228
+ this.stats.totalRequests++;
229
+ backend.connections++;
230
+ this.stats.activeConnections++;
231
+
232
+ const backendStats = this.stats.backendStats.find(s => s.id === backend.id);
233
+ if (backendStats) {
234
+ backendStats.requestsHandled++;
235
+ backendStats.connections = backend.connections;
236
+ backendStats.healthy = backend.healthy;
237
+ backendStats.responseTime = backend.responseTime;
238
+ }
239
+
240
+ try {
241
+ const startTime = Date.now();
242
+
243
+ // Forward request to backend
244
+ const url = new URL(request.url);
245
+ const backendUrl = `http://${backend.host}:${backend.port}${url.pathname}${url.search}`;
246
+
247
+ const response = await fetch(backendUrl, {
248
+ method: request.method,
249
+ headers: request.headers,
250
+ body: request.body,
251
+ signal: AbortSignal.timeout(5000),
252
+ });
253
+
254
+ const responseTime = Date.now() - startTime;
255
+ backend.responseTime = responseTime;
256
+
257
+ // Create response with backend data
258
+ const responseBody = await response.arrayBuffer();
259
+ const forwardedResponse = new Response(responseBody, {
260
+ status: response.status,
261
+ headers: response.headers,
262
+ });
263
+
264
+ // Add load balancer headers
265
+ forwardedResponse.headers.set('X-Load-Balancer-Backend', `${backend.host}:${backend.port}`);
266
+ forwardedResponse.headers.set('X-Load-Balancer-Response-Time', responseTime.toString());
267
+
268
+ return forwardedResponse;
269
+
270
+ } catch (error) {
271
+ backend.healthy = false;
272
+ return new Response('Bad Gateway', { status: 502 });
273
+ } finally {
274
+ // Update connection count
275
+ backend.connections--;
276
+ this.stats.activeConnections--;
277
+
278
+ if (backendStats) {
279
+ backendStats.connections = backend.connections;
280
+ }
281
+ }
282
+ }
283
+
284
+ public getStats(): LoadBalancerStats {
285
+ return { ...this.stats };
286
+ }
287
+
288
+ public getConfig(): LoadBalancerConfig {
289
+ return { ...this.config };
290
+ }
291
+
292
+ public updateConfig(newConfig: Partial<LoadBalancerConfig>): void {
293
+ this.config = { ...this.config, ...newConfig };
294
+
295
+ // Update backend stats if backends changed
296
+ if (newConfig.backends) {
297
+ this.initializeStats();
298
+ }
299
+ }
300
+ }
301
+
302
+ // CLI command for load balancer management
303
+ export async function loadbalancerCommand(action: string, options?: any): Promise<void> {
304
+ switch (action) {
305
+ case 'start':
306
+ await startLoadBalancer(options);
307
+ break;
308
+ case 'status':
309
+ await showLoadBalancerStatus(options);
310
+ break;
311
+ case 'config':
312
+ await configureLoadBalancer(options);
313
+ break;
314
+ default:
315
+ console.error('❌ Invalid action. Use: start, status, config');
316
+ process.exit(1);
317
+ }
318
+ }
319
+
320
+ async function startLoadBalancer(options?: any): Promise<void> {
321
+ const config: LoadBalancerConfig = {
322
+ port: options?.port || 8080,
323
+ algorithm: options?.algorithm || 'round-robin',
324
+ healthCheck: {
325
+ enabled: options?.healthCheck !== false,
326
+ path: options?.healthPath || '/healthz',
327
+ interval: options?.healthInterval || 10000,
328
+ timeout: options?.healthTimeout || 5000,
329
+ retries: options?.healthRetries || 3,
330
+ },
331
+ backends: parseBackends(options?.backends || []),
332
+ };
333
+
334
+ if (config.backends.length === 0) {
335
+ console.error('❌ At least one backend is required');
336
+ process.exit(1);
337
+ }
338
+
339
+ const loadBalancer = new LoadBalancer(config);
340
+
341
+ console.log(`🚀 Starting BS9 Load Balancer`);
342
+ console.log(`📡 Port: ${config.port}`);
343
+ console.log(`⚖️ Algorithm: ${config.algorithm}`);
344
+ console.log(`🏥 Health Check: ${config.healthCheck.enabled ? 'Enabled' : 'Disabled'}`);
345
+ console.log(`🔗 Backends: ${config.backends.length}`);
346
+
347
+ for (const backend of config.backends) {
348
+ console.log(` - ${backend.host}:${backend.port} (weight: ${backend.weight || 1})`);
349
+ }
350
+
351
+ // Start load balancer server
352
+ const server = serve({
353
+ port: config.port,
354
+ fetch: async (request) => {
355
+ // Handle load balancer API endpoints
356
+ const url = new URL(request.url);
357
+
358
+ if (url.pathname === '/lb-stats') {
359
+ return new Response(JSON.stringify(loadBalancer.getStats()), {
360
+ headers: { 'Content-Type': 'application/json' }
361
+ });
362
+ }
363
+
364
+ if (url.pathname === '/lb-config') {
365
+ return new Response(JSON.stringify(loadBalancer.getConfig()), {
366
+ headers: { 'Content-Type': 'application/json' }
367
+ });
368
+ }
369
+
370
+ // Forward all other requests
371
+ return loadBalancer.handleRequest(request);
372
+ },
373
+ });
374
+
375
+ console.log(`✅ Load balancer running on http://localhost:${config.port}`);
376
+ console.log(`📊 Stats: http://localhost:${config.port}/lb-stats`);
377
+ console.log(`⚙️ Config: http://localhost:${config.port}/lb-config`);
378
+
379
+ // Graceful shutdown
380
+ process.on('SIGINT', () => {
381
+ console.log('\n🛑 Shutting down load balancer...');
382
+ server.stop();
383
+ process.exit(0);
384
+ });
385
+ }
386
+
387
+ async function showLoadBalancerStatus(options?: any): Promise<void> {
388
+ const port = options?.port || 8080;
389
+
390
+ try {
391
+ const response = await fetch(`http://localhost:${port}/lb-stats`);
392
+ if (!response.ok) {
393
+ throw new Error(`HTTP ${response.status}`);
394
+ }
395
+
396
+ const stats: LoadBalancerStats = await response.json();
397
+
398
+ console.log('📊 Load Balancer Status');
399
+ console.log('='.repeat(60));
400
+ console.log(`Total Requests: ${stats.totalRequests}`);
401
+ console.log(`Active Connections: ${stats.activeConnections}`);
402
+ console.log(`Backends: ${stats.backendStats.length}`);
403
+
404
+ console.log('\n🔗 Backend Status:');
405
+ for (const backend of stats.backendStats) {
406
+ const statusIcon = backend.healthy ? '✅' : '❌';
407
+ console.log(`${statusIcon} ${backend.host}:${backend.port}`);
408
+ console.log(` Connections: ${backend.connections}`);
409
+ console.log(` Response Time: ${backend.responseTime}ms`);
410
+ console.log(` Requests Handled: ${backend.requestsHandled}`);
411
+ console.log('');
412
+ }
413
+
414
+ } catch (error) {
415
+ console.error(`❌ Failed to get load balancer status: ${error}`);
416
+ process.exit(1);
417
+ }
418
+ }
419
+
420
+ async function configureLoadBalancer(options?: any): Promise<void> {
421
+ const port = options?.port || 8080;
422
+
423
+ if (!options?.backends && !options?.algorithm) {
424
+ console.error('❌ No configuration changes specified');
425
+ process.exit(1);
426
+ }
427
+
428
+ try {
429
+ const configResponse = await fetch(`http://localhost:${port}/lb-config`);
430
+ if (!configResponse.ok) {
431
+ throw new Error(`HTTP ${configResponse.status}`);
432
+ }
433
+
434
+ const currentConfig: LoadBalancerConfig = await configResponse.json();
435
+
436
+ const newConfig: Partial<LoadBalancerConfig> = {};
437
+
438
+ if (options?.backends) {
439
+ newConfig.backends = parseBackends(options.backends);
440
+ }
441
+
442
+ if (options?.algorithm) {
443
+ newConfig.algorithm = options.algorithm;
444
+ }
445
+
446
+ // Apply configuration (this would need to be implemented in the load balancer)
447
+ console.log('📝 Load balancer configuration updated');
448
+ console.log('Note: Dynamic configuration updates require load balancer restart');
449
+
450
+ } catch (error) {
451
+ console.error(`❌ Failed to configure load balancer: ${error}`);
452
+ process.exit(1);
453
+ }
454
+ }
455
+
456
+ function parseBackends(backendsStr: string): BackendServer[] {
457
+ const backends: BackendServer[] = [];
458
+
459
+ for (const backendStr of backendsStr.split(',')) {
460
+ const [hostPort, weightStr] = backendStr.trim().split(':');
461
+ const [host, port] = hostPort.split('@');
462
+
463
+ if (!host || !port) {
464
+ console.error(`❌ Invalid backend format: ${backendStr}`);
465
+ process.exit(1);
466
+ }
467
+
468
+ backends.push({
469
+ id: `${host}:${port}`,
470
+ host,
471
+ port: parseInt(port),
472
+ weight: weightStr ? parseInt(weightStr) : 1,
473
+ connections: 0,
474
+ healthy: true,
475
+ lastHealthCheck: Date.now(),
476
+ responseTime: 0,
477
+ });
478
+ }
479
+
480
+ return backends;
481
+ }