@push.rocks/smartproxy 19.6.12 → 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.
- package/dist_ts/core/utils/log-deduplicator.d.ts +36 -0
- package/dist_ts/core/utils/log-deduplicator.js +224 -0
- package/dist_ts/core/utils/shared-security-manager.d.ts +2 -1
- package/dist_ts/core/utils/shared-security-manager.js +22 -2
- package/dist_ts/proxies/http-proxy/http-proxy.d.ts +1 -0
- package/dist_ts/proxies/http-proxy/http-proxy.js +94 -9
- package/dist_ts/proxies/http-proxy/models/types.d.ts +2 -0
- package/dist_ts/proxies/http-proxy/models/types.js +1 -1
- package/dist_ts/proxies/http-proxy/security-manager.d.ts +42 -1
- package/dist_ts/proxies/http-proxy/security-manager.js +121 -2
- package/dist_ts/proxies/smart-proxy/connection-manager.d.ts +14 -0
- package/dist_ts/proxies/smart-proxy/connection-manager.js +74 -26
- package/dist_ts/proxies/smart-proxy/http-proxy-bridge.js +5 -1
- package/dist_ts/proxies/smart-proxy/models/interfaces.d.ts +1 -0
- package/dist_ts/proxies/smart-proxy/route-connection-handler.js +24 -8
- package/dist_ts/proxies/smart-proxy/security-manager.d.ts +9 -0
- package/dist_ts/proxies/smart-proxy/security-manager.js +63 -1
- package/dist_ts/proxies/smart-proxy/smart-proxy.js +4 -1
- package/dist_ts/proxies/smart-proxy/throughput-tracker.js +8 -10
- package/package.json +1 -1
- package/readme.hints.md +121 -1
- package/readme.plan.md +34 -353
- package/ts/core/utils/log-deduplicator.ts +280 -0
- package/ts/core/utils/shared-security-manager.ts +24 -1
- package/ts/proxies/http-proxy/http-proxy.ts +129 -9
- package/ts/proxies/http-proxy/models/types.ts +4 -0
- package/ts/proxies/http-proxy/security-manager.ts +136 -1
- package/ts/proxies/smart-proxy/connection-manager.ts +93 -27
- package/ts/proxies/smart-proxy/http-proxy-bridge.ts +5 -0
- package/ts/proxies/smart-proxy/models/interfaces.ts +1 -0
- package/ts/proxies/smart-proxy/route-connection-handler.ts +45 -14
- package/ts/proxies/smart-proxy/security-manager.ts +76 -1
- package/ts/proxies/smart-proxy/smart-proxy.ts +4 -0
- package/ts/proxies/smart-proxy/throughput-tracker.ts +7 -13
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
288
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
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
|