@push.rocks/smartproxy 19.6.13 → 19.6.14

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 (32) hide show
  1. package/dist_ts/core/utils/log-deduplicator.d.ts +36 -0
  2. package/dist_ts/core/utils/log-deduplicator.js +224 -0
  3. package/dist_ts/core/utils/shared-security-manager.d.ts +2 -1
  4. package/dist_ts/core/utils/shared-security-manager.js +22 -2
  5. package/dist_ts/proxies/http-proxy/http-proxy.d.ts +1 -0
  6. package/dist_ts/proxies/http-proxy/http-proxy.js +94 -9
  7. package/dist_ts/proxies/http-proxy/models/types.d.ts +2 -0
  8. package/dist_ts/proxies/http-proxy/models/types.js +1 -1
  9. package/dist_ts/proxies/http-proxy/security-manager.d.ts +42 -1
  10. package/dist_ts/proxies/http-proxy/security-manager.js +121 -2
  11. package/dist_ts/proxies/smart-proxy/connection-manager.d.ts +14 -0
  12. package/dist_ts/proxies/smart-proxy/connection-manager.js +74 -26
  13. package/dist_ts/proxies/smart-proxy/http-proxy-bridge.js +5 -1
  14. package/dist_ts/proxies/smart-proxy/models/interfaces.d.ts +1 -0
  15. package/dist_ts/proxies/smart-proxy/route-connection-handler.js +24 -8
  16. package/dist_ts/proxies/smart-proxy/security-manager.d.ts +9 -0
  17. package/dist_ts/proxies/smart-proxy/security-manager.js +63 -1
  18. package/dist_ts/proxies/smart-proxy/smart-proxy.js +4 -1
  19. package/package.json +1 -1
  20. package/readme.hints.md +80 -1
  21. package/readme.plan.md +34 -353
  22. package/ts/core/utils/log-deduplicator.ts +280 -0
  23. package/ts/core/utils/shared-security-manager.ts +24 -1
  24. package/ts/proxies/http-proxy/http-proxy.ts +129 -9
  25. package/ts/proxies/http-proxy/models/types.ts +4 -0
  26. package/ts/proxies/http-proxy/security-manager.ts +136 -1
  27. package/ts/proxies/smart-proxy/connection-manager.ts +93 -27
  28. package/ts/proxies/smart-proxy/http-proxy-bridge.ts +5 -0
  29. package/ts/proxies/smart-proxy/models/interfaces.ts +1 -0
  30. package/ts/proxies/smart-proxy/route-connection-handler.ts +45 -14
  31. package/ts/proxies/smart-proxy/security-manager.ts +76 -1
  32. package/ts/proxies/smart-proxy/smart-proxy.ts +4 -0
@@ -152,9 +152,10 @@ export class SharedSecurityManager {
152
152
  *
153
153
  * @param route - The route to check
154
154
  * @param context - The request context
155
+ * @param routeConnectionCount - Current connection count for this route (optional)
155
156
  * @returns Whether access is allowed
156
157
  */
157
- public isAllowed(route: IRouteConfig, context: IRouteContext): boolean {
158
+ public isAllowed(route: IRouteConfig, context: IRouteContext, routeConnectionCount?: number): boolean {
158
159
  if (!route.security) {
159
160
  return true; // No security restrictions
160
161
  }
@@ -165,6 +166,14 @@ export class SharedSecurityManager {
165
166
  return false;
166
167
  }
167
168
 
169
+ // --- Route-level connection limit ---
170
+ if (route.security.maxConnections !== undefined && routeConnectionCount !== undefined) {
171
+ if (routeConnectionCount >= route.security.maxConnections) {
172
+ this.logger?.debug?.(`Route connection limit (${route.security.maxConnections}) exceeded for route ${route.name || 'unnamed'}`);
173
+ return false;
174
+ }
175
+ }
176
+
168
177
  // --- Rate limiting ---
169
178
  if (route.security.rateLimit?.enabled && !this.isWithinRateLimit(route, context)) {
170
179
  this.logger?.debug?.(`Rate limit exceeded for route ${route.name || 'unnamed'}`);
@@ -304,6 +313,20 @@ export class SharedSecurityManager {
304
313
  // Clean up rate limits
305
314
  cleanupExpiredRateLimits(this.rateLimits, this.logger);
306
315
 
316
+ // Clean up IP connection tracking
317
+ let cleanedIPs = 0;
318
+ for (const [ip, info] of this.connectionsByIP.entries()) {
319
+ // Remove IPs with no active connections and no recent timestamps
320
+ if (info.connections.size === 0 && info.timestamps.length === 0) {
321
+ this.connectionsByIP.delete(ip);
322
+ cleanedIPs++;
323
+ }
324
+ }
325
+
326
+ if (cleanedIPs > 0 && this.logger?.debug) {
327
+ this.logger.debug(`Cleaned up ${cleanedIPs} IPs with no active connections`);
328
+ }
329
+
307
330
  // IP filter cache doesn't need cleanup (tied to routes)
308
331
  }
309
332
 
@@ -17,6 +17,8 @@ import { WebSocketHandler } from './websocket-handler.js';
17
17
  import { HttpRouter } from '../../routing/router/index.js';
18
18
  import { cleanupSocket } from '../../core/utils/socket-utils.js';
19
19
  import { FunctionCache } from './function-cache.js';
20
+ import { SecurityManager } from './security-manager.js';
21
+ import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
20
22
 
21
23
  /**
22
24
  * HttpProxy provides a reverse proxy with TLS termination, WebSocket support,
@@ -43,6 +45,7 @@ export class HttpProxy implements IMetricsTracker {
43
45
  private router = new HttpRouter(); // Unified HTTP router
44
46
  private routeManager: RouteManager;
45
47
  private functionCache: FunctionCache;
48
+ private securityManager: SecurityManager;
46
49
 
47
50
  // State tracking
48
51
  public socketMap = new plugins.lik.ObjectMap<plugins.net.Socket>();
@@ -113,6 +116,14 @@ export class HttpProxy implements IMetricsTracker {
113
116
  maxCacheSize: this.options.functionCacheSize || 1000,
114
117
  defaultTtl: this.options.functionCacheTtl || 5000
115
118
  });
119
+
120
+ // Initialize security manager
121
+ this.securityManager = new SecurityManager(
122
+ this.logger,
123
+ [],
124
+ this.options.maxConnectionsPerIP || 100,
125
+ this.options.connectionRateLimitPerMinute || 300
126
+ );
116
127
 
117
128
  // Initialize other components
118
129
  this.certificateManager = new CertificateManager(this.options);
@@ -269,14 +280,113 @@ export class HttpProxy implements IMetricsTracker {
269
280
  */
270
281
  private setupConnectionTracking(): void {
271
282
  this.httpsServer.on('connection', (connection: plugins.net.Socket) => {
272
- // Check if max connections reached
283
+ let remoteIP = connection.remoteAddress || '';
284
+ const connectionId = Math.random().toString(36).substring(2, 15);
285
+ const isFromSmartProxy = this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1');
286
+
287
+ // For SmartProxy connections, wait for CLIENT_IP header
288
+ if (isFromSmartProxy) {
289
+ let headerBuffer = Buffer.alloc(0);
290
+ let headerParsed = false;
291
+
292
+ const parseHeader = (data: Buffer) => {
293
+ if (headerParsed) return data;
294
+
295
+ headerBuffer = Buffer.concat([headerBuffer, data]);
296
+ const headerStr = headerBuffer.toString();
297
+ const headerEnd = headerStr.indexOf('\r\n');
298
+
299
+ if (headerEnd !== -1) {
300
+ const header = headerStr.substring(0, headerEnd);
301
+ if (header.startsWith('CLIENT_IP:')) {
302
+ remoteIP = header.substring(10); // Extract IP after "CLIENT_IP:"
303
+ this.logger.debug(`Extracted client IP from SmartProxy: ${remoteIP}`);
304
+ }
305
+ headerParsed = true;
306
+
307
+ // Store the real IP on the connection
308
+ (connection as any)._realRemoteIP = remoteIP;
309
+
310
+ // Validate the real IP
311
+ const ipValidation = this.securityManager.validateIP(remoteIP);
312
+ if (!ipValidation.allowed) {
313
+ connectionLogDeduplicator.log(
314
+ 'ip-rejected',
315
+ 'warn',
316
+ `HttpProxy connection rejected (via SmartProxy)`,
317
+ { remoteIP, reason: ipValidation.reason, component: 'http-proxy' },
318
+ remoteIP
319
+ );
320
+ connection.destroy();
321
+ return null;
322
+ }
323
+
324
+ // Track connection by real IP
325
+ this.securityManager.trackConnectionByIP(remoteIP, connectionId);
326
+
327
+ // Return remaining data after header
328
+ return headerBuffer.slice(headerEnd + 2);
329
+ }
330
+ return null;
331
+ };
332
+
333
+ // Override the first data handler to parse header
334
+ const originalEmit = connection.emit;
335
+ connection.emit = function(event: string, ...args: any[]) {
336
+ if (event === 'data' && !headerParsed) {
337
+ const remaining = parseHeader(args[0]);
338
+ if (remaining && remaining.length > 0) {
339
+ // Call original emit with remaining data
340
+ return originalEmit.apply(connection, ['data', remaining]);
341
+ } else if (headerParsed) {
342
+ // Header parsed but no remaining data
343
+ return true;
344
+ }
345
+ // Header not complete yet, suppress this data event
346
+ return true;
347
+ }
348
+ return originalEmit.apply(connection, [event, ...args]);
349
+ } as any;
350
+ } else {
351
+ // Direct connection - validate immediately
352
+ const ipValidation = this.securityManager.validateIP(remoteIP);
353
+ if (!ipValidation.allowed) {
354
+ connectionLogDeduplicator.log(
355
+ 'ip-rejected',
356
+ 'warn',
357
+ `HttpProxy connection rejected`,
358
+ { remoteIP, reason: ipValidation.reason, component: 'http-proxy' },
359
+ remoteIP
360
+ );
361
+ connection.destroy();
362
+ return;
363
+ }
364
+
365
+ // Track connection by IP
366
+ this.securityManager.trackConnectionByIP(remoteIP, connectionId);
367
+ }
368
+
369
+ // Then check global max connections
273
370
  if (this.socketMap.getArray().length >= this.options.maxConnections) {
274
- this.logger.warn(`Max connections (${this.options.maxConnections}) reached, rejecting new connection`);
371
+ connectionLogDeduplicator.log(
372
+ 'connection-rejected',
373
+ 'warn',
374
+ 'HttpProxy max connections reached',
375
+ {
376
+ reason: 'global-limit',
377
+ currentConnections: this.socketMap.getArray().length,
378
+ maxConnections: this.options.maxConnections,
379
+ component: 'http-proxy'
380
+ },
381
+ 'http-proxy-global-limit'
382
+ );
275
383
  connection.destroy();
276
384
  return;
277
385
  }
278
-
279
- // Add connection to tracking
386
+
387
+ // Add connection to tracking with metadata
388
+ (connection as any)._connectionId = connectionId;
389
+ (connection as any)._remoteIP = remoteIP;
280
390
  this.socketMap.add(connection);
281
391
  this.connectedClients = this.socketMap.getArray().length;
282
392
 
@@ -284,12 +394,12 @@ export class HttpProxy implements IMetricsTracker {
284
394
  const localPort = connection.localPort || 0;
285
395
  const remotePort = connection.remotePort || 0;
286
396
 
287
- // If this connection is from a SmartProxy (usually indicated by it coming from localhost)
288
- if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) {
397
+ // If this connection is from a SmartProxy
398
+ if (isFromSmartProxy) {
289
399
  this.portProxyConnections++;
290
- this.logger.debug(`New connection from SmartProxy (local: ${localPort}, remote: ${remotePort})`);
400
+ this.logger.debug(`New connection from SmartProxy for client ${remoteIP} (local: ${localPort}, remote: ${remotePort})`);
291
401
  } else {
292
- this.logger.debug(`New direct connection (local: ${localPort}, remote: ${remotePort})`);
402
+ this.logger.debug(`New direct connection from ${remoteIP} (local: ${localPort}, remote: ${remotePort})`);
293
403
  }
294
404
 
295
405
  // Setup connection cleanup handlers
@@ -298,12 +408,19 @@ export class HttpProxy implements IMetricsTracker {
298
408
  this.socketMap.remove(connection);
299
409
  this.connectedClients = this.socketMap.getArray().length;
300
410
 
411
+ // Remove IP tracking
412
+ const connId = (connection as any)._connectionId;
413
+ const connIP = (connection as any)._realRemoteIP || (connection as any)._remoteIP;
414
+ if (connId && connIP) {
415
+ this.securityManager.removeConnectionByIP(connIP, connId);
416
+ }
417
+
301
418
  // If this was a SmartProxy connection, decrement the counter
302
419
  if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) {
303
420
  this.portProxyConnections--;
304
421
  }
305
422
 
306
- this.logger.debug(`Connection closed. ${this.connectedClients} connections remaining`);
423
+ this.logger.debug(`Connection closed from ${connIP || 'unknown'}. ${this.connectedClients} connections remaining`);
307
424
  }
308
425
  };
309
426
 
@@ -480,6 +597,9 @@ export class HttpProxy implements IMetricsTracker {
480
597
 
481
598
  // Certificate management cleanup is handled by SmartCertManager
482
599
 
600
+ // Flush any pending deduplicated logs
601
+ connectionLogDeduplicator.flushAll();
602
+
483
603
  // Close the HTTPS server
484
604
  return new Promise((resolve) => {
485
605
  this.httpsServer.close(() => {
@@ -45,6 +45,10 @@ export interface IHttpProxyOptions {
45
45
 
46
46
  // Direct route configurations
47
47
  routes?: IRouteConfig[];
48
+
49
+ // Rate limiting and security
50
+ maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP
51
+ connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP
48
52
  }
49
53
 
50
54
  /**
@@ -14,7 +14,14 @@ export class SecurityManager {
14
14
  // Store rate limits per route and key
15
15
  private rateLimits: Map<string, Map<string, { count: number, expiry: number }>> = new Map();
16
16
 
17
- constructor(private logger: ILogger, private routes: IRouteConfig[] = []) {}
17
+ // Connection tracking by IP
18
+ private connectionsByIP: Map<string, Set<string>> = new Map();
19
+ private connectionRateByIP: Map<string, number[]> = new Map();
20
+
21
+ constructor(private logger: ILogger, private routes: IRouteConfig[] = [], private maxConnectionsPerIP: number = 100, private connectionRateLimitPerMinute: number = 300) {
22
+ // Start periodic cleanup for connection tracking
23
+ this.startPeriodicIpCleanup();
24
+ }
18
25
 
19
26
  /**
20
27
  * Update the routes configuration
@@ -295,4 +302,132 @@ export class SecurityManager {
295
302
  return false;
296
303
  }
297
304
  }
305
+
306
+ /**
307
+ * Get connections count by IP
308
+ */
309
+ public getConnectionCountByIP(ip: string): number {
310
+ return this.connectionsByIP.get(ip)?.size || 0;
311
+ }
312
+
313
+ /**
314
+ * Check and update connection rate for an IP
315
+ * @returns true if within rate limit, false if exceeding limit
316
+ */
317
+ public checkConnectionRate(ip: string): boolean {
318
+ const now = Date.now();
319
+ const minute = 60 * 1000;
320
+
321
+ if (!this.connectionRateByIP.has(ip)) {
322
+ this.connectionRateByIP.set(ip, [now]);
323
+ return true;
324
+ }
325
+
326
+ // Get timestamps and filter out entries older than 1 minute
327
+ const timestamps = this.connectionRateByIP.get(ip)!.filter((time) => now - time < minute);
328
+ timestamps.push(now);
329
+ this.connectionRateByIP.set(ip, timestamps);
330
+
331
+ // Check if rate exceeds limit
332
+ return timestamps.length <= this.connectionRateLimitPerMinute;
333
+ }
334
+
335
+ /**
336
+ * Track connection by IP
337
+ */
338
+ public trackConnectionByIP(ip: string, connectionId: string): void {
339
+ if (!this.connectionsByIP.has(ip)) {
340
+ this.connectionsByIP.set(ip, new Set());
341
+ }
342
+ this.connectionsByIP.get(ip)!.add(connectionId);
343
+ }
344
+
345
+ /**
346
+ * Remove connection tracking for an IP
347
+ */
348
+ public removeConnectionByIP(ip: string, connectionId: string): void {
349
+ if (this.connectionsByIP.has(ip)) {
350
+ const connections = this.connectionsByIP.get(ip)!;
351
+ connections.delete(connectionId);
352
+ if (connections.size === 0) {
353
+ this.connectionsByIP.delete(ip);
354
+ }
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Check if IP should be allowed considering connection rate and max connections
360
+ * @returns Object with result and reason
361
+ */
362
+ public validateIP(ip: string): { allowed: boolean; reason?: string } {
363
+ // Check connection count limit
364
+ if (this.getConnectionCountByIP(ip) >= this.maxConnectionsPerIP) {
365
+ return {
366
+ allowed: false,
367
+ reason: `Maximum connections per IP (${this.maxConnectionsPerIP}) exceeded`
368
+ };
369
+ }
370
+
371
+ // Check connection rate limit
372
+ if (!this.checkConnectionRate(ip)) {
373
+ return {
374
+ allowed: false,
375
+ reason: `Connection rate limit (${this.connectionRateLimitPerMinute}/min) exceeded`
376
+ };
377
+ }
378
+
379
+ return { allowed: true };
380
+ }
381
+
382
+ /**
383
+ * Clears all IP tracking data (for shutdown)
384
+ */
385
+ public clearIPTracking(): void {
386
+ this.connectionsByIP.clear();
387
+ this.connectionRateByIP.clear();
388
+ }
389
+
390
+ /**
391
+ * Start periodic cleanup of IP tracking data
392
+ */
393
+ private startPeriodicIpCleanup(): void {
394
+ // Clean up IP tracking data every minute
395
+ setInterval(() => {
396
+ this.performIpCleanup();
397
+ }, 60000).unref();
398
+ }
399
+
400
+ /**
401
+ * Perform cleanup of expired IP data
402
+ */
403
+ private performIpCleanup(): void {
404
+ const now = Date.now();
405
+ const minute = 60 * 1000;
406
+ let cleanedRateLimits = 0;
407
+ let cleanedIPs = 0;
408
+
409
+ // Clean up expired rate limit timestamps
410
+ for (const [ip, timestamps] of this.connectionRateByIP.entries()) {
411
+ const validTimestamps = timestamps.filter(time => now - time < minute);
412
+
413
+ if (validTimestamps.length === 0) {
414
+ this.connectionRateByIP.delete(ip);
415
+ cleanedRateLimits++;
416
+ } else if (validTimestamps.length < timestamps.length) {
417
+ this.connectionRateByIP.set(ip, validTimestamps);
418
+ }
419
+ }
420
+
421
+ // Clean up IPs with no active connections
422
+ for (const [ip, connections] of this.connectionsByIP.entries()) {
423
+ if (connections.size === 0) {
424
+ this.connectionsByIP.delete(ip);
425
+ cleanedIPs++;
426
+ }
427
+ }
428
+
429
+ if (cleanedRateLimits > 0 || cleanedIPs > 0) {
430
+ this.logger.debug(`IP cleanup: removed ${cleanedIPs} IPs and ${cleanedRateLimits} rate limits`);
431
+ }
432
+ }
298
433
  }
@@ -1,6 +1,7 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import type { IConnectionRecord } from './models/interfaces.js';
3
3
  import { logger } from '../../core/utils/logger.js';
4
+ import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
4
5
  import { LifecycleComponent } from '../../core/utils/lifecycle-component.js';
5
6
  import { cleanupSocket } from '../../core/utils/socket-utils.js';
6
7
  import { WrappedSocket } from '../../core/models/wrapped-socket.js';
@@ -26,6 +27,10 @@ export class ConnectionManager extends LifecycleComponent {
26
27
  // Cleanup queue for batched processing
27
28
  private cleanupQueue: Set<string> = new Set();
28
29
  private cleanupTimer: NodeJS.Timeout | null = null;
30
+ private isProcessingCleanup: boolean = false;
31
+
32
+ // Route-level connection tracking
33
+ private connectionsByRoute: Map<string, Set<string>> = new Map();
29
34
 
30
35
  constructor(
31
36
  private smartProxy: SmartProxy
@@ -56,11 +61,19 @@ export class ConnectionManager extends LifecycleComponent {
56
61
  public createConnection(socket: plugins.net.Socket | WrappedSocket): IConnectionRecord | null {
57
62
  // Enforce connection limit
58
63
  if (this.connectionRecords.size >= this.maxConnections) {
59
- logger.log('warn', `Connection limit reached (${this.maxConnections}). Rejecting new connection.`, {
60
- currentConnections: this.connectionRecords.size,
61
- maxConnections: this.maxConnections,
62
- component: 'connection-manager'
63
- });
64
+ // Use deduplicated logging for connection limit
65
+ connectionLogDeduplicator.log(
66
+ 'connection-rejected',
67
+ 'warn',
68
+ 'Global connection limit reached',
69
+ {
70
+ reason: 'global-limit',
71
+ currentConnections: this.connectionRecords.size,
72
+ maxConnections: this.maxConnections,
73
+ component: 'connection-manager'
74
+ },
75
+ 'global-limit'
76
+ );
64
77
  socket.destroy();
65
78
  return null;
66
79
  }
@@ -165,18 +178,53 @@ export class ConnectionManager extends LifecycleComponent {
165
178
  return this.connectionRecords.size;
166
179
  }
167
180
 
181
+ /**
182
+ * Track connection by route
183
+ */
184
+ public trackConnectionByRoute(routeId: string, connectionId: string): void {
185
+ if (!this.connectionsByRoute.has(routeId)) {
186
+ this.connectionsByRoute.set(routeId, new Set());
187
+ }
188
+ this.connectionsByRoute.get(routeId)!.add(connectionId);
189
+ }
190
+
191
+ /**
192
+ * Remove connection tracking for a route
193
+ */
194
+ public removeConnectionByRoute(routeId: string, connectionId: string): void {
195
+ if (this.connectionsByRoute.has(routeId)) {
196
+ const connections = this.connectionsByRoute.get(routeId)!;
197
+ connections.delete(connectionId);
198
+ if (connections.size === 0) {
199
+ this.connectionsByRoute.delete(routeId);
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Get connection count by route
206
+ */
207
+ public getConnectionCountByRoute(routeId: string): number {
208
+ return this.connectionsByRoute.get(routeId)?.size || 0;
209
+ }
210
+
168
211
  /**
169
212
  * Initiates cleanup once for a connection
170
213
  */
171
214
  public initiateCleanupOnce(record: IConnectionRecord, reason: string = 'normal'): void {
172
- if (this.smartProxy.settings.enableDetailedLogging) {
173
- logger.log('info', `Connection cleanup initiated`, {
215
+ // Use deduplicated logging for cleanup events
216
+ connectionLogDeduplicator.log(
217
+ 'connection-cleanup',
218
+ 'info',
219
+ `Connection cleanup: ${reason}`,
220
+ {
174
221
  connectionId: record.id,
175
222
  remoteIP: record.remoteIP,
176
223
  reason,
177
224
  component: 'connection-manager'
178
- });
179
- }
225
+ },
226
+ reason
227
+ );
180
228
 
181
229
  if (record.incomingTerminationReason == null) {
182
230
  record.incomingTerminationReason = reason;
@@ -200,10 +248,10 @@ export class ConnectionManager extends LifecycleComponent {
200
248
 
201
249
  this.cleanupQueue.add(connectionId);
202
250
 
203
- // Process immediately if queue is getting large
204
- if (this.cleanupQueue.size >= this.cleanupBatchSize) {
251
+ // Process immediately if queue is getting large and not already processing
252
+ if (this.cleanupQueue.size >= this.cleanupBatchSize && !this.isProcessingCleanup) {
205
253
  this.processCleanupQueue();
206
- } else if (!this.cleanupTimer) {
254
+ } else if (!this.cleanupTimer && !this.isProcessingCleanup) {
207
255
  // Otherwise, schedule batch processing
208
256
  this.cleanupTimer = this.setTimeout(() => {
209
257
  this.processCleanupQueue();
@@ -215,27 +263,40 @@ export class ConnectionManager extends LifecycleComponent {
215
263
  * Process the cleanup queue in batches
216
264
  */
217
265
  private processCleanupQueue(): void {
266
+ // Prevent concurrent processing
267
+ if (this.isProcessingCleanup) {
268
+ return;
269
+ }
270
+
271
+ this.isProcessingCleanup = true;
272
+
218
273
  if (this.cleanupTimer) {
219
274
  this.clearTimeout(this.cleanupTimer);
220
275
  this.cleanupTimer = null;
221
276
  }
222
277
 
223
- const toCleanup = Array.from(this.cleanupQueue).slice(0, this.cleanupBatchSize);
224
-
225
- // Remove only the items we're processing, not the entire queue!
226
- for (const connectionId of toCleanup) {
227
- this.cleanupQueue.delete(connectionId);
228
- const record = this.connectionRecords.get(connectionId);
229
- if (record) {
230
- this.cleanupConnection(record, record.incomingTerminationReason || 'normal');
278
+ try {
279
+ // Take a snapshot of items to process
280
+ const toCleanup = Array.from(this.cleanupQueue).slice(0, this.cleanupBatchSize);
281
+
282
+ // Remove only the items we're processing from the queue
283
+ for (const connectionId of toCleanup) {
284
+ this.cleanupQueue.delete(connectionId);
285
+ const record = this.connectionRecords.get(connectionId);
286
+ if (record) {
287
+ this.cleanupConnection(record, record.incomingTerminationReason || 'normal');
288
+ }
289
+ }
290
+ } finally {
291
+ // Always reset the processing flag
292
+ this.isProcessingCleanup = false;
293
+
294
+ // Check if more items were added while we were processing
295
+ if (this.cleanupQueue.size > 0) {
296
+ this.cleanupTimer = this.setTimeout(() => {
297
+ this.processCleanupQueue();
298
+ }, 10);
231
299
  }
232
- }
233
-
234
- // If there are more in queue, schedule next batch
235
- if (this.cleanupQueue.size > 0) {
236
- this.cleanupTimer = this.setTimeout(() => {
237
- this.processCleanupQueue();
238
- }, 10);
239
300
  }
240
301
  }
241
302
 
@@ -252,6 +313,11 @@ export class ConnectionManager extends LifecycleComponent {
252
313
  // Track connection termination
253
314
  this.smartProxy.securityManager.removeConnectionByIP(record.remoteIP, record.id);
254
315
 
316
+ // Remove from route tracking
317
+ if (record.routeId) {
318
+ this.removeConnectionByRoute(record.routeId, record.id);
319
+ }
320
+
255
321
  // Remove from metrics tracking
256
322
  if (this.smartProxy.metricsCollector) {
257
323
  this.smartProxy.metricsCollector.removeConnection(record.id);
@@ -121,6 +121,11 @@ export class HttpProxyBridge {
121
121
  proxySocket.on('error', reject);
122
122
  });
123
123
 
124
+ // Send client IP information header first (custom protocol)
125
+ // Format: "CLIENT_IP:<ip>\r\n"
126
+ const clientIPHeader = Buffer.from(`CLIENT_IP:${record.remoteIP}\r\n`);
127
+ proxySocket.write(clientIPHeader);
128
+
124
129
  // Send initial chunk if present
125
130
  if (initialChunk) {
126
131
  // Count the initial chunk bytes
@@ -165,6 +165,7 @@ export interface IConnectionRecord {
165
165
  tlsHandshakeComplete: boolean; // Whether the TLS handshake is complete
166
166
  hasReceivedInitialData: boolean; // Whether initial data has been received
167
167
  routeConfig?: IRouteConfig; // Associated route config for this connection
168
+ routeId?: string; // ID of the route this connection is associated with
168
169
 
169
170
  // Target information (for dynamic port/host mapping)
170
171
  targetHost?: string; // Resolved target host