@push.rocks/smartproxy 3.24.0 → 3.25.1
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/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.portproxy.d.ts +62 -2
- package/dist_ts/classes.portproxy.js +462 -88
- package/package.json +1 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.portproxy.ts +578 -118
package/ts/classes.portproxy.ts
CHANGED
|
@@ -7,8 +7,14 @@ export interface IDomainConfig {
|
|
|
7
7
|
blockedIPs?: string[]; // Glob patterns for blocked IPs
|
|
8
8
|
targetIPs?: string[]; // If multiple targetIPs are given, use round robin.
|
|
9
9
|
portRanges?: Array<{ from: number; to: number }>; // Optional port ranges
|
|
10
|
+
// Protocol-specific timeout overrides
|
|
11
|
+
httpTimeout?: number; // HTTP connection timeout override (ms)
|
|
12
|
+
wsTimeout?: number; // WebSocket connection timeout override (ms)
|
|
10
13
|
}
|
|
11
14
|
|
|
15
|
+
/** Connection protocol types for timeout management */
|
|
16
|
+
export type ProtocolType = 'http' | 'websocket' | 'https' | 'tls' | 'unknown';
|
|
17
|
+
|
|
12
18
|
/** Port proxy settings including global allowed port ranges */
|
|
13
19
|
export interface IPortProxySettings extends plugins.tls.TlsOptions {
|
|
14
20
|
fromPort: number;
|
|
@@ -19,17 +25,66 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions {
|
|
|
19
25
|
defaultAllowedIPs?: string[];
|
|
20
26
|
defaultBlockedIPs?: string[];
|
|
21
27
|
preserveSourceIP?: boolean;
|
|
22
|
-
|
|
28
|
+
|
|
29
|
+
// Updated timeout settings with better defaults
|
|
30
|
+
initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 15000 (15s)
|
|
31
|
+
socketTimeout?: number; // Socket inactivity timeout (ms), default: 300000 (5m)
|
|
32
|
+
inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 30000 (30s)
|
|
33
|
+
|
|
34
|
+
// Protocol-specific timeouts
|
|
35
|
+
maxConnectionLifetime?: number; // Default max connection lifetime (ms), default: 3600000 (1h)
|
|
36
|
+
httpConnectionTimeout?: number; // HTTP specific timeout (ms), default: 1800000 (30m)
|
|
37
|
+
wsConnectionTimeout?: number; // WebSocket specific timeout (ms), default: 14400000 (4h)
|
|
38
|
+
httpKeepAliveTimeout?: number; // HTTP keep-alive header timeout (ms), default: 1200000 (20m)
|
|
39
|
+
|
|
40
|
+
gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
|
|
23
41
|
globalPortRanges: Array<{ from: number; to: number }>; // Global allowed port ranges
|
|
24
|
-
forwardAllGlobalRanges?: boolean;
|
|
25
|
-
gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
|
|
42
|
+
forwardAllGlobalRanges?: boolean; // When true, forwards all connections on global port ranges to the global targetIP
|
|
26
43
|
|
|
27
44
|
// Socket optimization settings
|
|
28
|
-
noDelay?: boolean;
|
|
29
|
-
keepAlive?: boolean;
|
|
30
|
-
keepAliveInitialDelay?: number;
|
|
31
|
-
maxPendingDataSize?: number;
|
|
32
|
-
|
|
45
|
+
noDelay?: boolean; // Disable Nagle's algorithm (default: true)
|
|
46
|
+
keepAlive?: boolean; // Enable TCP keepalive (default: true)
|
|
47
|
+
keepAliveInitialDelay?: number; // Initial delay before sending keepalive probes (ms)
|
|
48
|
+
maxPendingDataSize?: number; // Maximum bytes to buffer during connection setup
|
|
49
|
+
|
|
50
|
+
// Enable enhanced features
|
|
51
|
+
disableInactivityCheck?: boolean; // Disable inactivity checking entirely
|
|
52
|
+
enableKeepAliveProbes?: boolean; // Enable TCP keep-alive probes
|
|
53
|
+
enableProtocolDetection?: boolean; // Enable HTTP/WebSocket protocol detection
|
|
54
|
+
enableDetailedLogging?: boolean; // Enable detailed connection logging
|
|
55
|
+
|
|
56
|
+
// Rate limiting and security
|
|
57
|
+
maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP
|
|
58
|
+
connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Enhanced connection record with protocol-specific handling
|
|
63
|
+
*/
|
|
64
|
+
interface IConnectionRecord {
|
|
65
|
+
id: string; // Unique connection identifier
|
|
66
|
+
incoming: plugins.net.Socket;
|
|
67
|
+
outgoing: plugins.net.Socket | null;
|
|
68
|
+
incomingStartTime: number;
|
|
69
|
+
outgoingStartTime?: number;
|
|
70
|
+
outgoingClosedTime?: number;
|
|
71
|
+
lockedDomain?: string; // Used to lock this connection to the initial SNI
|
|
72
|
+
connectionClosed: boolean; // Flag to prevent multiple cleanup attempts
|
|
73
|
+
cleanupTimer?: NodeJS.Timeout; // Timer for max lifetime/inactivity
|
|
74
|
+
lastActivity: number; // Last activity timestamp for inactivity detection
|
|
75
|
+
pendingData: Buffer[]; // Buffer to hold data during connection setup
|
|
76
|
+
pendingDataSize: number; // Track total size of pending data
|
|
77
|
+
|
|
78
|
+
// Enhanced tracking fields
|
|
79
|
+
protocolType: ProtocolType; // Connection protocol type
|
|
80
|
+
isPooledConnection: boolean; // Whether this is likely a browser pooled connection
|
|
81
|
+
lastHttpRequest?: number; // Timestamp of last HTTP request (for keep-alive tracking)
|
|
82
|
+
httpKeepAliveTimeout?: number; // HTTP keep-alive timeout from headers
|
|
83
|
+
bytesReceived: number; // Total bytes received
|
|
84
|
+
bytesSent: number; // Total bytes sent
|
|
85
|
+
remoteIP: string; // Remote IP (cached for logging after socket close)
|
|
86
|
+
localPort: number; // Local port (cached for logging)
|
|
87
|
+
httpRequests: number; // Count of HTTP requests on this connection
|
|
33
88
|
}
|
|
34
89
|
|
|
35
90
|
/**
|
|
@@ -95,21 +150,6 @@ function extractSNI(buffer: Buffer): string | undefined {
|
|
|
95
150
|
return undefined;
|
|
96
151
|
}
|
|
97
152
|
|
|
98
|
-
interface IConnectionRecord {
|
|
99
|
-
id: string; // Unique connection identifier
|
|
100
|
-
incoming: plugins.net.Socket;
|
|
101
|
-
outgoing: plugins.net.Socket | null;
|
|
102
|
-
incomingStartTime: number;
|
|
103
|
-
outgoingStartTime?: number;
|
|
104
|
-
outgoingClosedTime?: number;
|
|
105
|
-
lockedDomain?: string; // Used to lock this connection to the initial SNI
|
|
106
|
-
connectionClosed: boolean; // Flag to prevent multiple cleanup attempts
|
|
107
|
-
cleanupTimer?: NodeJS.Timeout; // Timer for max lifetime/inactivity
|
|
108
|
-
lastActivity: number; // Last activity timestamp for inactivity detection
|
|
109
|
-
pendingData: Buffer[]; // Buffer to hold data during connection setup
|
|
110
|
-
pendingDataSize: number; // Track total size of pending data
|
|
111
|
-
}
|
|
112
|
-
|
|
113
153
|
// Helper: Check if a port falls within any of the given port ranges
|
|
114
154
|
const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => {
|
|
115
155
|
return ranges.some(range => port >= range.from && port <= range.to);
|
|
@@ -145,6 +185,34 @@ const generateConnectionId = (): string => {
|
|
|
145
185
|
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
|
146
186
|
};
|
|
147
187
|
|
|
188
|
+
// Protocol detection helpers
|
|
189
|
+
const isHttpRequest = (buffer: Buffer): boolean => {
|
|
190
|
+
if (buffer.length < 4) return false;
|
|
191
|
+
const start = buffer.toString('ascii', 0, 4).toUpperCase();
|
|
192
|
+
return (
|
|
193
|
+
start.startsWith('GET ') ||
|
|
194
|
+
start.startsWith('POST') ||
|
|
195
|
+
start.startsWith('PUT ') ||
|
|
196
|
+
start.startsWith('HEAD') ||
|
|
197
|
+
start.startsWith('DELE') ||
|
|
198
|
+
start.startsWith('PATC') ||
|
|
199
|
+
start.startsWith('OPTI')
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const isWebSocketUpgrade = (buffer: Buffer): boolean => {
|
|
204
|
+
if (buffer.length < 20) return false;
|
|
205
|
+
const data = buffer.toString('ascii', 0, Math.min(buffer.length, 200));
|
|
206
|
+
return (
|
|
207
|
+
data.includes('Upgrade: websocket') ||
|
|
208
|
+
data.includes('Upgrade: WebSocket')
|
|
209
|
+
);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const isTlsHandshake = (buffer: Buffer): boolean => {
|
|
213
|
+
return buffer.length > 0 && buffer[0] === 22; // ContentType.handshake
|
|
214
|
+
};
|
|
215
|
+
|
|
148
216
|
export class PortProxy {
|
|
149
217
|
private netServers: plugins.net.Server[] = [];
|
|
150
218
|
settings: IPortProxySettings;
|
|
@@ -155,6 +223,7 @@ export class PortProxy {
|
|
|
155
223
|
// Map to track round robin indices for each domain config
|
|
156
224
|
private domainTargetIndices: Map<IDomainConfig, number> = new Map();
|
|
157
225
|
|
|
226
|
+
// Enhanced stats tracking
|
|
158
227
|
private terminationStats: {
|
|
159
228
|
incoming: Record<string, number>;
|
|
160
229
|
outgoing: Record<string, number>;
|
|
@@ -162,25 +231,218 @@ export class PortProxy {
|
|
|
162
231
|
incoming: {},
|
|
163
232
|
outgoing: {},
|
|
164
233
|
};
|
|
234
|
+
|
|
235
|
+
// Connection tracking by IP for rate limiting
|
|
236
|
+
private connectionsByIP: Map<string, Set<string>> = new Map();
|
|
237
|
+
private connectionRateByIP: Map<string, number[]> = new Map();
|
|
165
238
|
|
|
166
239
|
constructor(settingsArg: IPortProxySettings) {
|
|
240
|
+
// Set reasonable defaults for all settings
|
|
167
241
|
this.settings = {
|
|
168
242
|
...settingsArg,
|
|
169
243
|
targetIP: settingsArg.targetIP || 'localhost',
|
|
170
|
-
|
|
171
|
-
|
|
244
|
+
|
|
245
|
+
// Timeout settings with browser-friendly defaults
|
|
246
|
+
initialDataTimeout: settingsArg.initialDataTimeout || 15000, // 15 seconds
|
|
247
|
+
socketTimeout: settingsArg.socketTimeout || 300000, // 5 minutes
|
|
248
|
+
inactivityCheckInterval: settingsArg.inactivityCheckInterval || 30000, // 30 seconds
|
|
249
|
+
|
|
250
|
+
// Protocol-specific timeouts
|
|
251
|
+
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 3600000, // 1 hour default
|
|
252
|
+
httpConnectionTimeout: settingsArg.httpConnectionTimeout || 1800000, // 30 minutes
|
|
253
|
+
wsConnectionTimeout: settingsArg.wsConnectionTimeout || 14400000, // 4 hours
|
|
254
|
+
httpKeepAliveTimeout: settingsArg.httpKeepAliveTimeout || 1200000, // 20 minutes
|
|
255
|
+
|
|
256
|
+
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000, // 30 seconds
|
|
257
|
+
|
|
258
|
+
// Socket optimization settings
|
|
172
259
|
noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true,
|
|
173
260
|
keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true,
|
|
174
261
|
keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 60000, // 1 minute
|
|
175
262
|
maxPendingDataSize: settingsArg.maxPendingDataSize || 1024 * 1024, // 1MB
|
|
176
|
-
|
|
263
|
+
|
|
264
|
+
// Feature flags
|
|
265
|
+
disableInactivityCheck: settingsArg.disableInactivityCheck || false,
|
|
266
|
+
enableKeepAliveProbes: settingsArg.enableKeepAliveProbes || false,
|
|
267
|
+
enableProtocolDetection: settingsArg.enableProtocolDetection !== undefined ? settingsArg.enableProtocolDetection : true,
|
|
268
|
+
enableDetailedLogging: settingsArg.enableDetailedLogging || false,
|
|
269
|
+
|
|
270
|
+
// Rate limiting defaults
|
|
271
|
+
maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100, // 100 connections per IP
|
|
272
|
+
connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300, // 300 per minute
|
|
177
273
|
};
|
|
178
274
|
}
|
|
179
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Get connections count by IP
|
|
278
|
+
*/
|
|
279
|
+
private getConnectionCountByIP(ip: string): number {
|
|
280
|
+
return this.connectionsByIP.get(ip)?.size || 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Check and update connection rate for an IP
|
|
285
|
+
*/
|
|
286
|
+
private checkConnectionRate(ip: string): boolean {
|
|
287
|
+
const now = Date.now();
|
|
288
|
+
const minute = 60 * 1000;
|
|
289
|
+
|
|
290
|
+
if (!this.connectionRateByIP.has(ip)) {
|
|
291
|
+
this.connectionRateByIP.set(ip, [now]);
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Get timestamps and filter out entries older than 1 minute
|
|
296
|
+
const timestamps = this.connectionRateByIP.get(ip)!.filter(time => now - time < minute);
|
|
297
|
+
timestamps.push(now);
|
|
298
|
+
this.connectionRateByIP.set(ip, timestamps);
|
|
299
|
+
|
|
300
|
+
// Check if rate exceeds limit
|
|
301
|
+
return timestamps.length <= this.settings.connectionRateLimitPerMinute!;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Track connection by IP
|
|
306
|
+
*/
|
|
307
|
+
private trackConnectionByIP(ip: string, connectionId: string): void {
|
|
308
|
+
if (!this.connectionsByIP.has(ip)) {
|
|
309
|
+
this.connectionsByIP.set(ip, new Set());
|
|
310
|
+
}
|
|
311
|
+
this.connectionsByIP.get(ip)!.add(connectionId);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Remove connection tracking for an IP
|
|
316
|
+
*/
|
|
317
|
+
private removeConnectionByIP(ip: string, connectionId: string): void {
|
|
318
|
+
if (this.connectionsByIP.has(ip)) {
|
|
319
|
+
const connections = this.connectionsByIP.get(ip)!;
|
|
320
|
+
connections.delete(connectionId);
|
|
321
|
+
if (connections.size === 0) {
|
|
322
|
+
this.connectionsByIP.delete(ip);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Track connection termination statistic
|
|
329
|
+
*/
|
|
180
330
|
private incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void {
|
|
181
331
|
this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1;
|
|
182
332
|
}
|
|
183
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Get protocol-specific timeout based on connection type
|
|
336
|
+
*/
|
|
337
|
+
private getProtocolTimeout(record: IConnectionRecord, domainConfig?: IDomainConfig): number {
|
|
338
|
+
// If the protocol has a domain-specific timeout, use that
|
|
339
|
+
if (domainConfig) {
|
|
340
|
+
if (record.protocolType === 'http' && domainConfig.httpTimeout) {
|
|
341
|
+
return domainConfig.httpTimeout;
|
|
342
|
+
}
|
|
343
|
+
if (record.protocolType === 'websocket' && domainConfig.wsTimeout) {
|
|
344
|
+
return domainConfig.wsTimeout;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Use HTTP keep-alive timeout from headers if available
|
|
349
|
+
if (record.httpKeepAliveTimeout) {
|
|
350
|
+
return record.httpKeepAliveTimeout;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Otherwise use default protocol-specific timeout
|
|
354
|
+
switch (record.protocolType) {
|
|
355
|
+
case 'http':
|
|
356
|
+
return this.settings.httpConnectionTimeout!;
|
|
357
|
+
case 'websocket':
|
|
358
|
+
return this.settings.wsConnectionTimeout!;
|
|
359
|
+
case 'https':
|
|
360
|
+
case 'tls':
|
|
361
|
+
return this.settings.httpConnectionTimeout!; // Use HTTP timeout for HTTPS by default
|
|
362
|
+
default:
|
|
363
|
+
return this.settings.maxConnectionLifetime!;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Detect protocol and update connection record
|
|
369
|
+
*/
|
|
370
|
+
private detectProtocol(data: Buffer, record: IConnectionRecord): void {
|
|
371
|
+
if (!this.settings.enableProtocolDetection || record.protocolType !== 'unknown') {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
// Detect TLS/HTTPS
|
|
377
|
+
if (isTlsHandshake(data)) {
|
|
378
|
+
record.protocolType = 'tls';
|
|
379
|
+
if (this.settings.enableDetailedLogging) {
|
|
380
|
+
console.log(`[${record.id}] Protocol detected: TLS`);
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Detect HTTP including WebSocket upgrades
|
|
386
|
+
if (isHttpRequest(data)) {
|
|
387
|
+
record.httpRequests++;
|
|
388
|
+
record.lastHttpRequest = Date.now();
|
|
389
|
+
|
|
390
|
+
// Check for WebSocket upgrade
|
|
391
|
+
if (isWebSocketUpgrade(data)) {
|
|
392
|
+
record.protocolType = 'websocket';
|
|
393
|
+
if (this.settings.enableDetailedLogging) {
|
|
394
|
+
console.log(`[${record.id}] Protocol detected: WebSocket Upgrade`);
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
record.protocolType = 'http';
|
|
398
|
+
|
|
399
|
+
// Parse HTTP keep-alive headers
|
|
400
|
+
this.parseHttpHeaders(data, record);
|
|
401
|
+
|
|
402
|
+
if (this.settings.enableDetailedLogging) {
|
|
403
|
+
console.log(`[${record.id}] Protocol detected: HTTP${record.isPooledConnection ? ' (KeepAlive)' : ''}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
} catch (err) {
|
|
408
|
+
console.log(`[${record.id}] Error detecting protocol: ${err}`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Parse HTTP headers for keep-alive and other connection info
|
|
414
|
+
*/
|
|
415
|
+
private parseHttpHeaders(data: Buffer, record: IConnectionRecord): void {
|
|
416
|
+
try {
|
|
417
|
+
const headerStr = data.toString('utf8', 0, Math.min(data.length, 1024));
|
|
418
|
+
|
|
419
|
+
// Check for HTTP keep-alive
|
|
420
|
+
const connectionHeader = headerStr.match(/\r\nConnection:\s*([^\r\n]+)/i);
|
|
421
|
+
if (connectionHeader && connectionHeader[1].toLowerCase().includes('keep-alive')) {
|
|
422
|
+
record.isPooledConnection = true;
|
|
423
|
+
|
|
424
|
+
// Check for Keep-Alive timeout value
|
|
425
|
+
const keepAliveHeader = headerStr.match(/\r\nKeep-Alive:\s*([^\r\n]+)/i);
|
|
426
|
+
if (keepAliveHeader) {
|
|
427
|
+
const timeoutMatch = keepAliveHeader[1].match(/timeout=(\d+)/i);
|
|
428
|
+
if (timeoutMatch && timeoutMatch[1]) {
|
|
429
|
+
const timeoutSec = parseInt(timeoutMatch[1], 10);
|
|
430
|
+
if (!isNaN(timeoutSec) && timeoutSec > 0) {
|
|
431
|
+
// Convert seconds to milliseconds and add some buffer
|
|
432
|
+
record.httpKeepAliveTimeout = (timeoutSec * 1000) + 5000;
|
|
433
|
+
|
|
434
|
+
if (this.settings.enableDetailedLogging) {
|
|
435
|
+
console.log(`[${record.id}] HTTP Keep-Alive timeout set to ${timeoutSec} seconds`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
} catch (err) {
|
|
442
|
+
console.log(`[${record.id}] Error parsing HTTP headers: ${err}`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
184
446
|
/**
|
|
185
447
|
* Cleans up a connection record.
|
|
186
448
|
* Destroys both incoming and outgoing sockets, clears timers, and removes the record.
|
|
@@ -191,11 +453,20 @@ export class PortProxy {
|
|
|
191
453
|
if (!record.connectionClosed) {
|
|
192
454
|
record.connectionClosed = true;
|
|
193
455
|
|
|
456
|
+
// Track connection termination
|
|
457
|
+
this.removeConnectionByIP(record.remoteIP, record.id);
|
|
458
|
+
|
|
194
459
|
if (record.cleanupTimer) {
|
|
195
460
|
clearTimeout(record.cleanupTimer);
|
|
196
461
|
record.cleanupTimer = undefined;
|
|
197
462
|
}
|
|
198
463
|
|
|
464
|
+
// Detailed logging data
|
|
465
|
+
const duration = Date.now() - record.incomingStartTime;
|
|
466
|
+
const bytesReceived = record.bytesReceived;
|
|
467
|
+
const bytesSent = record.bytesSent;
|
|
468
|
+
const httpRequests = record.httpRequests;
|
|
469
|
+
|
|
199
470
|
try {
|
|
200
471
|
if (!record.incoming.destroyed) {
|
|
201
472
|
// Try graceful shutdown first, then force destroy after a short timeout
|
|
@@ -206,7 +477,7 @@ export class PortProxy {
|
|
|
206
477
|
record.incoming.destroy();
|
|
207
478
|
}
|
|
208
479
|
} catch (err) {
|
|
209
|
-
console.log(`Error destroying incoming socket: ${err}`);
|
|
480
|
+
console.log(`[${record.id}] Error destroying incoming socket: ${err}`);
|
|
210
481
|
}
|
|
211
482
|
}, 1000);
|
|
212
483
|
|
|
@@ -216,13 +487,13 @@ export class PortProxy {
|
|
|
216
487
|
}
|
|
217
488
|
}
|
|
218
489
|
} catch (err) {
|
|
219
|
-
console.log(`Error closing incoming socket: ${err}`);
|
|
490
|
+
console.log(`[${record.id}] Error closing incoming socket: ${err}`);
|
|
220
491
|
try {
|
|
221
492
|
if (!record.incoming.destroyed) {
|
|
222
493
|
record.incoming.destroy();
|
|
223
494
|
}
|
|
224
495
|
} catch (destroyErr) {
|
|
225
|
-
console.log(`Error destroying incoming socket: ${destroyErr}`);
|
|
496
|
+
console.log(`[${record.id}] Error destroying incoming socket: ${destroyErr}`);
|
|
226
497
|
}
|
|
227
498
|
}
|
|
228
499
|
|
|
@@ -236,7 +507,7 @@ export class PortProxy {
|
|
|
236
507
|
record.outgoing.destroy();
|
|
237
508
|
}
|
|
238
509
|
} catch (err) {
|
|
239
|
-
console.log(`Error destroying outgoing socket: ${err}`);
|
|
510
|
+
console.log(`[${record.id}] Error destroying outgoing socket: ${err}`);
|
|
240
511
|
}
|
|
241
512
|
}, 1000);
|
|
242
513
|
|
|
@@ -246,13 +517,13 @@ export class PortProxy {
|
|
|
246
517
|
}
|
|
247
518
|
}
|
|
248
519
|
} catch (err) {
|
|
249
|
-
console.log(`Error closing outgoing socket: ${err}`);
|
|
520
|
+
console.log(`[${record.id}] Error closing outgoing socket: ${err}`);
|
|
250
521
|
try {
|
|
251
522
|
if (record.outgoing && !record.outgoing.destroyed) {
|
|
252
523
|
record.outgoing.destroy();
|
|
253
524
|
}
|
|
254
525
|
} catch (destroyErr) {
|
|
255
|
-
console.log(`Error destroying outgoing socket: ${destroyErr}`);
|
|
526
|
+
console.log(`[${record.id}] Error destroying outgoing socket: ${destroyErr}`);
|
|
256
527
|
}
|
|
257
528
|
}
|
|
258
529
|
|
|
@@ -263,15 +534,27 @@ export class PortProxy {
|
|
|
263
534
|
// Remove the record from the tracking map
|
|
264
535
|
this.connectionRecords.delete(record.id);
|
|
265
536
|
|
|
266
|
-
|
|
267
|
-
|
|
537
|
+
// Log connection details
|
|
538
|
+
if (this.settings.enableDetailedLogging) {
|
|
539
|
+
console.log(`[${record.id}] Connection from ${record.remoteIP} on port ${record.localPort} terminated (${reason}).` +
|
|
540
|
+
` Duration: ${plugins.prettyMs(duration)}, Bytes IN: ${bytesReceived}, OUT: ${bytesSent}, ` +
|
|
541
|
+
`HTTP Requests: ${httpRequests}, Protocol: ${record.protocolType}, Pooled: ${record.isPooledConnection}`);
|
|
542
|
+
} else {
|
|
543
|
+
console.log(`[${record.id}] Connection from ${record.remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}`);
|
|
544
|
+
}
|
|
268
545
|
}
|
|
269
546
|
}
|
|
270
547
|
|
|
548
|
+
/**
|
|
549
|
+
* Update connection activity timestamp
|
|
550
|
+
*/
|
|
271
551
|
private updateActivity(record: IConnectionRecord): void {
|
|
272
552
|
record.lastActivity = Date.now();
|
|
273
553
|
}
|
|
274
554
|
|
|
555
|
+
/**
|
|
556
|
+
* Get target IP with round-robin support
|
|
557
|
+
*/
|
|
275
558
|
private getTargetIP(domainConfig: IDomainConfig): string {
|
|
276
559
|
if (domainConfig.targetIPs && domainConfig.targetIPs.length > 0) {
|
|
277
560
|
const currentIndex = this.domainTargetIndices.get(domainConfig) || 0;
|
|
@@ -282,12 +565,16 @@ export class PortProxy {
|
|
|
282
565
|
return this.settings.targetIP!;
|
|
283
566
|
}
|
|
284
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Main method to start the proxy
|
|
570
|
+
*/
|
|
285
571
|
public async start() {
|
|
286
572
|
// Don't start if already shutting down
|
|
287
573
|
if (this.isShuttingDown) {
|
|
288
574
|
console.log("Cannot start PortProxy while it's shutting down");
|
|
289
575
|
return;
|
|
290
576
|
}
|
|
577
|
+
|
|
291
578
|
// Define a unified connection handler for all listening ports.
|
|
292
579
|
const connectionHandler = (socket: plugins.net.Socket) => {
|
|
293
580
|
if (this.isShuttingDown) {
|
|
@@ -297,12 +584,31 @@ export class PortProxy {
|
|
|
297
584
|
}
|
|
298
585
|
|
|
299
586
|
const remoteIP = socket.remoteAddress || '';
|
|
300
|
-
const localPort = socket.localPort; // The port on which this connection was accepted.
|
|
587
|
+
const localPort = socket.localPort || 0; // The port on which this connection was accepted.
|
|
588
|
+
|
|
589
|
+
// Check rate limits
|
|
590
|
+
if (this.settings.maxConnectionsPerIP &&
|
|
591
|
+
this.getConnectionCountByIP(remoteIP) >= this.settings.maxConnectionsPerIP) {
|
|
592
|
+
console.log(`Connection rejected from ${remoteIP}: Maximum connections per IP (${this.settings.maxConnectionsPerIP}) exceeded`);
|
|
593
|
+
socket.end();
|
|
594
|
+
socket.destroy();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (this.settings.connectionRateLimitPerMinute && !this.checkConnectionRate(remoteIP)) {
|
|
599
|
+
console.log(`Connection rejected from ${remoteIP}: Connection rate limit (${this.settings.connectionRateLimitPerMinute}/min) exceeded`);
|
|
600
|
+
socket.end();
|
|
601
|
+
socket.destroy();
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
301
604
|
|
|
302
605
|
// Apply socket optimizations
|
|
303
606
|
socket.setNoDelay(this.settings.noDelay);
|
|
304
|
-
|
|
607
|
+
if (this.settings.keepAlive) {
|
|
608
|
+
socket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
|
|
609
|
+
}
|
|
305
610
|
|
|
611
|
+
// Create a unique connection ID and record
|
|
306
612
|
const connectionId = generateConnectionId();
|
|
307
613
|
const connectionRecord: IConnectionRecord = {
|
|
308
614
|
id: connectionId,
|
|
@@ -311,12 +617,28 @@ export class PortProxy {
|
|
|
311
617
|
incomingStartTime: Date.now(),
|
|
312
618
|
lastActivity: Date.now(),
|
|
313
619
|
connectionClosed: false,
|
|
314
|
-
pendingData: [],
|
|
315
|
-
pendingDataSize: 0
|
|
620
|
+
pendingData: [],
|
|
621
|
+
pendingDataSize: 0,
|
|
622
|
+
|
|
623
|
+
// Initialize enhanced tracking fields
|
|
624
|
+
protocolType: 'unknown',
|
|
625
|
+
isPooledConnection: false,
|
|
626
|
+
bytesReceived: 0,
|
|
627
|
+
bytesSent: 0,
|
|
628
|
+
remoteIP: remoteIP,
|
|
629
|
+
localPort: localPort,
|
|
630
|
+
httpRequests: 0
|
|
316
631
|
};
|
|
632
|
+
|
|
633
|
+
// Track connection by IP
|
|
634
|
+
this.trackConnectionByIP(remoteIP, connectionId);
|
|
317
635
|
this.connectionRecords.set(connectionId, connectionRecord);
|
|
318
636
|
|
|
319
|
-
|
|
637
|
+
if (this.settings.enableDetailedLogging) {
|
|
638
|
+
console.log(`[${connectionId}] New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionRecords.size}`);
|
|
639
|
+
} else {
|
|
640
|
+
console.log(`New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionRecords.size}`);
|
|
641
|
+
}
|
|
320
642
|
|
|
321
643
|
let initialDataReceived = false;
|
|
322
644
|
let incomingTerminationReason: string | null = null;
|
|
@@ -327,15 +649,21 @@ export class PortProxy {
|
|
|
327
649
|
this.cleanupConnection(connectionRecord);
|
|
328
650
|
};
|
|
329
651
|
|
|
330
|
-
// Define initiateCleanupOnce for compatibility
|
|
652
|
+
// Define initiateCleanupOnce for compatibility
|
|
331
653
|
const initiateCleanupOnce = (reason: string = 'normal') => {
|
|
332
|
-
|
|
654
|
+
if (this.settings.enableDetailedLogging) {
|
|
655
|
+
console.log(`[${connectionId}] Connection cleanup initiated for ${remoteIP} (${reason})`);
|
|
656
|
+
}
|
|
657
|
+
if (incomingTerminationReason === null) {
|
|
658
|
+
incomingTerminationReason = reason;
|
|
659
|
+
this.incrementTerminationStat('incoming', reason);
|
|
660
|
+
}
|
|
333
661
|
cleanupOnce();
|
|
334
662
|
};
|
|
335
663
|
|
|
336
664
|
// Helper to reject an incoming connection
|
|
337
665
|
const rejectIncomingConnection = (reason: string, logMessage: string) => {
|
|
338
|
-
console.log(logMessage);
|
|
666
|
+
console.log(`[${connectionId}] ${logMessage}`);
|
|
339
667
|
socket.end();
|
|
340
668
|
if (incomingTerminationReason === null) {
|
|
341
669
|
incomingTerminationReason = reason;
|
|
@@ -349,7 +677,7 @@ export class PortProxy {
|
|
|
349
677
|
if (this.settings.sniEnabled) {
|
|
350
678
|
initialTimeout = setTimeout(() => {
|
|
351
679
|
if (!initialDataReceived) {
|
|
352
|
-
console.log(`Initial data timeout (${this.settings.initialDataTimeout}ms) for connection from ${remoteIP} on port ${localPort}`);
|
|
680
|
+
console.log(`[${connectionId}] Initial data timeout (${this.settings.initialDataTimeout}ms) for connection from ${remoteIP} on port ${localPort}`);
|
|
353
681
|
if (incomingTerminationReason === null) {
|
|
354
682
|
incomingTerminationReason = 'initial_timeout';
|
|
355
683
|
this.incrementTerminationStat('incoming', 'initial_timeout');
|
|
@@ -357,24 +685,75 @@ export class PortProxy {
|
|
|
357
685
|
socket.end();
|
|
358
686
|
cleanupOnce();
|
|
359
687
|
}
|
|
360
|
-
}, this.settings.initialDataTimeout
|
|
688
|
+
}, this.settings.initialDataTimeout);
|
|
361
689
|
} else {
|
|
362
690
|
initialDataReceived = true;
|
|
363
691
|
}
|
|
364
692
|
|
|
365
693
|
socket.on('error', (err: Error) => {
|
|
366
|
-
console.log(`Incoming socket error from ${remoteIP}: ${err.message}`);
|
|
694
|
+
console.log(`[${connectionId}] Incoming socket error from ${remoteIP}: ${err.message}`);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// Track data for bytes counting
|
|
698
|
+
socket.on('data', (chunk: Buffer) => {
|
|
699
|
+
connectionRecord.bytesReceived += chunk.length;
|
|
700
|
+
this.updateActivity(connectionRecord);
|
|
701
|
+
|
|
702
|
+
// Detect protocol on first data chunk
|
|
703
|
+
if (connectionRecord.protocolType === 'unknown') {
|
|
704
|
+
this.detectProtocol(chunk, connectionRecord);
|
|
705
|
+
|
|
706
|
+
// Update timeout based on protocol
|
|
707
|
+
if (connectionRecord.cleanupTimer) {
|
|
708
|
+
clearTimeout(connectionRecord.cleanupTimer);
|
|
709
|
+
|
|
710
|
+
// Set new timeout based on protocol
|
|
711
|
+
const protocolTimeout = this.getProtocolTimeout(connectionRecord);
|
|
712
|
+
connectionRecord.cleanupTimer = setTimeout(() => {
|
|
713
|
+
console.log(`[${connectionId}] ${connectionRecord.protocolType} connection timeout after ${plugins.prettyMs(protocolTimeout)}`);
|
|
714
|
+
initiateCleanupOnce(`${connectionRecord.protocolType}_timeout`);
|
|
715
|
+
}, protocolTimeout);
|
|
716
|
+
}
|
|
717
|
+
} else if (connectionRecord.protocolType === 'http' && isHttpRequest(chunk)) {
|
|
718
|
+
// Additional HTTP request on the same connection
|
|
719
|
+
connectionRecord.httpRequests++;
|
|
720
|
+
connectionRecord.lastHttpRequest = Date.now();
|
|
721
|
+
|
|
722
|
+
// Parse HTTP headers again for keep-alive changes
|
|
723
|
+
this.parseHttpHeaders(chunk, connectionRecord);
|
|
724
|
+
|
|
725
|
+
// Update timeout based on new HTTP headers
|
|
726
|
+
if (connectionRecord.cleanupTimer) {
|
|
727
|
+
clearTimeout(connectionRecord.cleanupTimer);
|
|
728
|
+
|
|
729
|
+
// Set new timeout based on updated HTTP info
|
|
730
|
+
const protocolTimeout = this.getProtocolTimeout(connectionRecord);
|
|
731
|
+
connectionRecord.cleanupTimer = setTimeout(() => {
|
|
732
|
+
console.log(`[${connectionId}] HTTP connection timeout after ${plugins.prettyMs(protocolTimeout)}`);
|
|
733
|
+
initiateCleanupOnce('http_timeout');
|
|
734
|
+
}, protocolTimeout);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
367
737
|
});
|
|
368
738
|
|
|
369
739
|
const handleError = (side: 'incoming' | 'outgoing') => (err: Error) => {
|
|
370
740
|
const code = (err as any).code;
|
|
371
741
|
let reason = 'error';
|
|
742
|
+
|
|
743
|
+
const now = Date.now();
|
|
744
|
+
const connectionDuration = now - connectionRecord.incomingStartTime;
|
|
745
|
+
const lastActivityAge = now - connectionRecord.lastActivity;
|
|
746
|
+
|
|
372
747
|
if (code === 'ECONNRESET') {
|
|
373
748
|
reason = 'econnreset';
|
|
374
|
-
console.log(`ECONNRESET on ${side} side from ${remoteIP}: ${err.message}`);
|
|
749
|
+
console.log(`[${connectionId}] ECONNRESET on ${side} side from ${remoteIP}: ${err.message}. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago`);
|
|
750
|
+
} else if (code === 'ETIMEDOUT') {
|
|
751
|
+
reason = 'etimedout';
|
|
752
|
+
console.log(`[${connectionId}] ETIMEDOUT on ${side} side from ${remoteIP}: ${err.message}. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago`);
|
|
375
753
|
} else {
|
|
376
|
-
console.log(`Error on ${side} side from ${remoteIP}: ${err.message}`);
|
|
754
|
+
console.log(`[${connectionId}] Error on ${side} side from ${remoteIP}: ${err.message}. Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago`);
|
|
377
755
|
}
|
|
756
|
+
|
|
378
757
|
if (side === 'incoming' && incomingTerminationReason === null) {
|
|
379
758
|
incomingTerminationReason = reason;
|
|
380
759
|
this.incrementTerminationStat('incoming', reason);
|
|
@@ -382,11 +761,15 @@ export class PortProxy {
|
|
|
382
761
|
outgoingTerminationReason = reason;
|
|
383
762
|
this.incrementTerminationStat('outgoing', reason);
|
|
384
763
|
}
|
|
764
|
+
|
|
385
765
|
initiateCleanupOnce(reason);
|
|
386
766
|
};
|
|
387
767
|
|
|
388
768
|
const handleClose = (side: 'incoming' | 'outgoing') => () => {
|
|
389
|
-
|
|
769
|
+
if (this.settings.enableDetailedLogging) {
|
|
770
|
+
console.log(`[${connectionId}] Connection closed on ${side} side from ${remoteIP}`);
|
|
771
|
+
}
|
|
772
|
+
|
|
390
773
|
if (side === 'incoming' && incomingTerminationReason === null) {
|
|
391
774
|
incomingTerminationReason = 'normal';
|
|
392
775
|
this.incrementTerminationStat('incoming', 'normal');
|
|
@@ -396,6 +779,7 @@ export class PortProxy {
|
|
|
396
779
|
// Record the time when outgoing socket closed.
|
|
397
780
|
connectionRecord.outgoingClosedTime = Date.now();
|
|
398
781
|
}
|
|
782
|
+
|
|
399
783
|
initiateCleanupOnce('closed_' + side);
|
|
400
784
|
};
|
|
401
785
|
|
|
@@ -413,6 +797,11 @@ export class PortProxy {
|
|
|
413
797
|
initialTimeout = null;
|
|
414
798
|
}
|
|
415
799
|
|
|
800
|
+
// Detect protocol if initial chunk is available
|
|
801
|
+
if (initialChunk && this.settings.enableProtocolDetection) {
|
|
802
|
+
this.detectProtocol(initialChunk, connectionRecord);
|
|
803
|
+
}
|
|
804
|
+
|
|
416
805
|
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
|
|
417
806
|
const domainConfig = forcedDomain
|
|
418
807
|
? forcedDomain
|
|
@@ -455,11 +844,19 @@ export class PortProxy {
|
|
|
455
844
|
|
|
456
845
|
// Temporary handler to collect data during connection setup
|
|
457
846
|
const tempDataHandler = (chunk: Buffer) => {
|
|
847
|
+
// Track bytes received
|
|
848
|
+
connectionRecord.bytesReceived += chunk.length;
|
|
849
|
+
|
|
850
|
+
// Detect protocol even during connection setup
|
|
851
|
+
if (this.settings.enableProtocolDetection && connectionRecord.protocolType === 'unknown') {
|
|
852
|
+
this.detectProtocol(chunk, connectionRecord);
|
|
853
|
+
}
|
|
854
|
+
|
|
458
855
|
// Check if adding this chunk would exceed the buffer limit
|
|
459
856
|
const newSize = connectionRecord.pendingDataSize + chunk.length;
|
|
460
857
|
|
|
461
858
|
if (this.settings.maxPendingDataSize && newSize > this.settings.maxPendingDataSize) {
|
|
462
|
-
console.log(`Buffer limit exceeded for connection from ${remoteIP}: ${newSize} bytes > ${this.settings.maxPendingDataSize} bytes`);
|
|
859
|
+
console.log(`[${connectionId}] Buffer limit exceeded for connection from ${remoteIP}: ${newSize} bytes > ${this.settings.maxPendingDataSize} bytes`);
|
|
463
860
|
socket.end(); // Gracefully close the socket
|
|
464
861
|
return initiateCleanupOnce('buffer_limit_exceeded');
|
|
465
862
|
}
|
|
@@ -475,6 +872,7 @@ export class PortProxy {
|
|
|
475
872
|
|
|
476
873
|
// Add initial chunk to pending data if present
|
|
477
874
|
if (initialChunk) {
|
|
875
|
+
connectionRecord.bytesReceived += initialChunk.length;
|
|
478
876
|
connectionRecord.pendingData.push(Buffer.from(initialChunk));
|
|
479
877
|
connectionRecord.pendingDataSize = initialChunk.length;
|
|
480
878
|
}
|
|
@@ -486,25 +884,27 @@ export class PortProxy {
|
|
|
486
884
|
|
|
487
885
|
// Apply socket optimizations
|
|
488
886
|
targetSocket.setNoDelay(this.settings.noDelay);
|
|
489
|
-
|
|
887
|
+
if (this.settings.keepAlive) {
|
|
888
|
+
targetSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
|
|
889
|
+
}
|
|
490
890
|
|
|
491
891
|
// Setup specific error handler for connection phase
|
|
492
892
|
targetSocket.once('error', (err) => {
|
|
493
893
|
// This handler runs only once during the initial connection phase
|
|
494
894
|
const code = (err as any).code;
|
|
495
|
-
console.log(`Connection setup error to ${targetHost}:${connectionOptions.port}: ${err.message} (${code})`);
|
|
895
|
+
console.log(`[${connectionId}] Connection setup error to ${targetHost}:${connectionOptions.port}: ${err.message} (${code})`);
|
|
496
896
|
|
|
497
897
|
// Resume the incoming socket to prevent it from hanging
|
|
498
898
|
socket.resume();
|
|
499
899
|
|
|
500
900
|
if (code === 'ECONNREFUSED') {
|
|
501
|
-
console.log(`Target ${targetHost}:${connectionOptions.port} refused connection`);
|
|
901
|
+
console.log(`[${connectionId}] Target ${targetHost}:${connectionOptions.port} refused connection`);
|
|
502
902
|
} else if (code === 'ETIMEDOUT') {
|
|
503
|
-
console.log(`Connection to ${targetHost}:${connectionOptions.port} timed out`);
|
|
903
|
+
console.log(`[${connectionId}] Connection to ${targetHost}:${connectionOptions.port} timed out`);
|
|
504
904
|
} else if (code === 'ECONNRESET') {
|
|
505
|
-
console.log(`Connection to ${targetHost}:${connectionOptions.port} was reset`);
|
|
905
|
+
console.log(`[${connectionId}] Connection to ${targetHost}:${connectionOptions.port} was reset`);
|
|
506
906
|
} else if (code === 'EHOSTUNREACH') {
|
|
507
|
-
console.log(`Host ${targetHost} is unreachable`);
|
|
907
|
+
console.log(`[${connectionId}] Host ${targetHost} is unreachable`);
|
|
508
908
|
}
|
|
509
909
|
|
|
510
910
|
// Clear any existing error handler after connection phase
|
|
@@ -528,15 +928,16 @@ export class PortProxy {
|
|
|
528
928
|
|
|
529
929
|
// Handle timeouts
|
|
530
930
|
socket.on('timeout', () => {
|
|
531
|
-
console.log(`Timeout on incoming side from ${remoteIP}`);
|
|
931
|
+
console.log(`[${connectionId}] Timeout on incoming side from ${remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 300000)}`);
|
|
532
932
|
if (incomingTerminationReason === null) {
|
|
533
933
|
incomingTerminationReason = 'timeout';
|
|
534
934
|
this.incrementTerminationStat('incoming', 'timeout');
|
|
535
935
|
}
|
|
536
936
|
initiateCleanupOnce('timeout_incoming');
|
|
537
937
|
});
|
|
938
|
+
|
|
538
939
|
targetSocket.on('timeout', () => {
|
|
539
|
-
console.log(`Timeout on outgoing side from ${remoteIP}`);
|
|
940
|
+
console.log(`[${connectionId}] Timeout on outgoing side from ${remoteIP} after ${plugins.prettyMs(this.settings.socketTimeout || 300000)}`);
|
|
540
941
|
if (outgoingTerminationReason === null) {
|
|
541
942
|
outgoingTerminationReason = 'timeout';
|
|
542
943
|
this.incrementTerminationStat('outgoing', 'timeout');
|
|
@@ -544,9 +945,15 @@ export class PortProxy {
|
|
|
544
945
|
initiateCleanupOnce('timeout_outgoing');
|
|
545
946
|
});
|
|
546
947
|
|
|
547
|
-
// Set appropriate timeouts
|
|
548
|
-
socket.setTimeout(
|
|
549
|
-
targetSocket.setTimeout(
|
|
948
|
+
// Set appropriate timeouts using the configured value
|
|
949
|
+
socket.setTimeout(this.settings.socketTimeout || 300000);
|
|
950
|
+
targetSocket.setTimeout(this.settings.socketTimeout || 300000);
|
|
951
|
+
|
|
952
|
+
// Track outgoing data for bytes counting
|
|
953
|
+
targetSocket.on('data', (chunk: Buffer) => {
|
|
954
|
+
connectionRecord.bytesSent += chunk.length;
|
|
955
|
+
this.updateActivity(connectionRecord);
|
|
956
|
+
});
|
|
550
957
|
|
|
551
958
|
// Wait for the outgoing connection to be ready before setting up piping
|
|
552
959
|
targetSocket.once('connect', () => {
|
|
@@ -564,7 +971,7 @@ export class PortProxy {
|
|
|
564
971
|
const combinedData = Buffer.concat(connectionRecord.pendingData);
|
|
565
972
|
targetSocket.write(combinedData, (err) => {
|
|
566
973
|
if (err) {
|
|
567
|
-
console.log(`Error writing pending data to target: ${err.message}`);
|
|
974
|
+
console.log(`[${connectionId}] Error writing pending data to target: ${err.message}`);
|
|
568
975
|
return initiateCleanupOnce('write_error');
|
|
569
976
|
}
|
|
570
977
|
|
|
@@ -573,10 +980,18 @@ export class PortProxy {
|
|
|
573
980
|
targetSocket.pipe(socket);
|
|
574
981
|
socket.resume(); // Resume the socket after piping is established
|
|
575
982
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
983
|
+
if (this.settings.enableDetailedLogging) {
|
|
984
|
+
console.log(
|
|
985
|
+
`[${connectionId}] Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
|
|
986
|
+
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}` +
|
|
987
|
+
` Protocol: ${connectionRecord.protocolType}`
|
|
988
|
+
);
|
|
989
|
+
} else {
|
|
990
|
+
console.log(
|
|
991
|
+
`Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
|
|
992
|
+
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}`
|
|
993
|
+
);
|
|
994
|
+
}
|
|
580
995
|
});
|
|
581
996
|
} else {
|
|
582
997
|
// No pending data, so just set up piping
|
|
@@ -584,27 +999,25 @@ export class PortProxy {
|
|
|
584
999
|
targetSocket.pipe(socket);
|
|
585
1000
|
socket.resume(); // Resume the socket after piping is established
|
|
586
1001
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
1002
|
+
if (this.settings.enableDetailedLogging) {
|
|
1003
|
+
console.log(
|
|
1004
|
+
`[${connectionId}] Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
|
|
1005
|
+
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}` +
|
|
1006
|
+
` Protocol: ${connectionRecord.protocolType}`
|
|
1007
|
+
);
|
|
1008
|
+
} else {
|
|
1009
|
+
console.log(
|
|
1010
|
+
`Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
|
|
1011
|
+
`${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}`
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
591
1014
|
}
|
|
592
1015
|
|
|
593
1016
|
// Clear the buffer now that we've processed it
|
|
594
1017
|
connectionRecord.pendingData = [];
|
|
595
1018
|
connectionRecord.pendingDataSize = 0;
|
|
596
1019
|
|
|
597
|
-
//
|
|
598
|
-
socket.on('data', () => {
|
|
599
|
-
connectionRecord.lastActivity = Date.now();
|
|
600
|
-
});
|
|
601
|
-
|
|
602
|
-
targetSocket.on('data', () => {
|
|
603
|
-
connectionRecord.lastActivity = Date.now();
|
|
604
|
-
});
|
|
605
|
-
|
|
606
|
-
// Add the renegotiation listener (we don't need setImmediate here anymore
|
|
607
|
-
// since we're already in the connect callback)
|
|
1020
|
+
// Add the renegotiation listener for SNI validation
|
|
608
1021
|
if (serverName) {
|
|
609
1022
|
socket.on('data', (renegChunk: Buffer) => {
|
|
610
1023
|
if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
|
|
@@ -612,26 +1025,30 @@ export class PortProxy {
|
|
|
612
1025
|
// Try to extract SNI from potential renegotiation
|
|
613
1026
|
const newSNI = extractSNI(renegChunk);
|
|
614
1027
|
if (newSNI && newSNI !== connectionRecord.lockedDomain) {
|
|
615
|
-
console.log(`Rehandshake detected with different SNI: ${newSNI} vs locked ${connectionRecord.lockedDomain}. Terminating connection.`);
|
|
1028
|
+
console.log(`[${connectionId}] Rehandshake detected with different SNI: ${newSNI} vs locked ${connectionRecord.lockedDomain}. Terminating connection.`);
|
|
616
1029
|
initiateCleanupOnce('sni_mismatch');
|
|
617
|
-
} else if (newSNI) {
|
|
618
|
-
console.log(`Rehandshake detected with same SNI: ${newSNI}. Allowing.`);
|
|
1030
|
+
} else if (newSNI && this.settings.enableDetailedLogging) {
|
|
1031
|
+
console.log(`[${connectionId}] Rehandshake detected with same SNI: ${newSNI}. Allowing.`);
|
|
619
1032
|
}
|
|
620
1033
|
} catch (err) {
|
|
621
|
-
console.log(`Error processing potential renegotiation: ${err}. Allowing connection to continue.`);
|
|
1034
|
+
console.log(`[${connectionId}] Error processing potential renegotiation: ${err}. Allowing connection to continue.`);
|
|
622
1035
|
}
|
|
623
1036
|
}
|
|
624
1037
|
});
|
|
625
1038
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
1039
|
+
|
|
1040
|
+
// Set protocol-specific timeout based on detected protocol
|
|
1041
|
+
if (connectionRecord.cleanupTimer) {
|
|
1042
|
+
clearTimeout(connectionRecord.cleanupTimer);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Set timeout based on protocol
|
|
1046
|
+
const protocolTimeout = this.getProtocolTimeout(connectionRecord, domainConfig);
|
|
630
1047
|
connectionRecord.cleanupTimer = setTimeout(() => {
|
|
631
|
-
console.log(`
|
|
632
|
-
initiateCleanupOnce(
|
|
633
|
-
},
|
|
634
|
-
}
|
|
1048
|
+
console.log(`[${connectionId}] ${connectionRecord.protocolType} connection exceeded max lifetime (${plugins.prettyMs(protocolTimeout)}), forcing cleanup.`);
|
|
1049
|
+
initiateCleanupOnce(`${connectionRecord.protocolType}_max_lifetime`);
|
|
1050
|
+
}, protocolTimeout);
|
|
1051
|
+
});
|
|
635
1052
|
};
|
|
636
1053
|
|
|
637
1054
|
// --- PORT RANGE-BASED HANDLING ---
|
|
@@ -639,11 +1056,13 @@ export class PortProxy {
|
|
|
639
1056
|
if (this.settings.globalPortRanges && isPortInRanges(localPort, this.settings.globalPortRanges)) {
|
|
640
1057
|
if (this.settings.forwardAllGlobalRanges) {
|
|
641
1058
|
if (this.settings.defaultAllowedIPs && !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
|
|
642
|
-
console.log(`Connection from ${remoteIP} rejected: IP ${remoteIP} not allowed in global default allowed list.`);
|
|
1059
|
+
console.log(`[${connectionId}] Connection from ${remoteIP} rejected: IP ${remoteIP} not allowed in global default allowed list.`);
|
|
643
1060
|
socket.end();
|
|
644
1061
|
return;
|
|
645
1062
|
}
|
|
646
|
-
|
|
1063
|
+
if (this.settings.enableDetailedLogging) {
|
|
1064
|
+
console.log(`[${connectionId}] Port-based connection from ${remoteIP} on port ${localPort} forwarded to global target IP ${this.settings.targetIP}.`);
|
|
1065
|
+
}
|
|
647
1066
|
setupConnection('', undefined, {
|
|
648
1067
|
domains: ['global'],
|
|
649
1068
|
allowedIPs: this.settings.defaultAllowedIPs || [],
|
|
@@ -667,11 +1086,13 @@ export class PortProxy {
|
|
|
667
1086
|
...(this.settings.defaultBlockedIPs || [])
|
|
668
1087
|
];
|
|
669
1088
|
if (!isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
|
|
670
|
-
console.log(`Connection from ${remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join(', ')} on port ${localPort}.`);
|
|
1089
|
+
console.log(`[${connectionId}] Connection from ${remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join(', ')} on port ${localPort}.`);
|
|
671
1090
|
socket.end();
|
|
672
1091
|
return;
|
|
673
1092
|
}
|
|
674
|
-
|
|
1093
|
+
if (this.settings.enableDetailedLogging) {
|
|
1094
|
+
console.log(`[${connectionId}] Port-based connection from ${remoteIP} on port ${localPort} matched domain ${forcedDomain.domains.join(', ')}.`);
|
|
1095
|
+
}
|
|
675
1096
|
setupConnection('', undefined, forcedDomain, localPort);
|
|
676
1097
|
return;
|
|
677
1098
|
}
|
|
@@ -693,7 +1114,10 @@ export class PortProxy {
|
|
|
693
1114
|
const serverName = extractSNI(chunk) || '';
|
|
694
1115
|
// Lock the connection to the negotiated SNI.
|
|
695
1116
|
connectionRecord.lockedDomain = serverName;
|
|
696
|
-
|
|
1117
|
+
|
|
1118
|
+
if (this.settings.enableDetailedLogging) {
|
|
1119
|
+
console.log(`[${connectionId}] Received connection from ${remoteIP} with SNI: ${serverName || '(empty)'}`);
|
|
1120
|
+
}
|
|
697
1121
|
|
|
698
1122
|
setupConnection(serverName, chunk);
|
|
699
1123
|
});
|
|
@@ -735,15 +1159,19 @@ export class PortProxy {
|
|
|
735
1159
|
this.netServers.push(server);
|
|
736
1160
|
}
|
|
737
1161
|
|
|
738
|
-
// Log active connection count, longest running durations, and run parity checks
|
|
1162
|
+
// Log active connection count, longest running durations, and run parity checks periodically
|
|
739
1163
|
this.connectionLogger = setInterval(() => {
|
|
740
1164
|
// Immediately return if shutting down
|
|
741
1165
|
if (this.isShuttingDown) return;
|
|
742
|
-
if (this.isShuttingDown) return;
|
|
743
1166
|
|
|
744
1167
|
const now = Date.now();
|
|
745
1168
|
let maxIncoming = 0;
|
|
746
1169
|
let maxOutgoing = 0;
|
|
1170
|
+
let httpConnections = 0;
|
|
1171
|
+
let wsConnections = 0;
|
|
1172
|
+
let tlsConnections = 0;
|
|
1173
|
+
let unknownConnections = 0;
|
|
1174
|
+
let pooledConnections = 0;
|
|
747
1175
|
|
|
748
1176
|
// Create a copy of the keys to avoid modification during iteration
|
|
749
1177
|
const connectionIds = [...this.connectionRecords.keys()];
|
|
@@ -752,6 +1180,19 @@ export class PortProxy {
|
|
|
752
1180
|
const record = this.connectionRecords.get(id);
|
|
753
1181
|
if (!record) continue;
|
|
754
1182
|
|
|
1183
|
+
// Track connection stats by protocol
|
|
1184
|
+
switch (record.protocolType) {
|
|
1185
|
+
case 'http': httpConnections++; break;
|
|
1186
|
+
case 'websocket': wsConnections++; break;
|
|
1187
|
+
case 'tls':
|
|
1188
|
+
case 'https': tlsConnections++; break;
|
|
1189
|
+
default: unknownConnections++; break;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
if (record.isPooledConnection) {
|
|
1193
|
+
pooledConnections++;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
755
1196
|
maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
|
|
756
1197
|
if (record.outgoingStartTime) {
|
|
757
1198
|
maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
|
|
@@ -762,30 +1203,51 @@ export class PortProxy {
|
|
|
762
1203
|
!record.incoming.destroyed &&
|
|
763
1204
|
!record.connectionClosed &&
|
|
764
1205
|
(now - record.outgoingClosedTime > 30000)) {
|
|
765
|
-
const remoteIP = record.
|
|
766
|
-
console.log(`Parity check: Incoming socket for ${remoteIP} still active ${plugins.prettyMs(now - record.outgoingClosedTime)} after outgoing closed.`);
|
|
1206
|
+
const remoteIP = record.remoteIP;
|
|
1207
|
+
console.log(`[${id}] Parity check: Incoming socket for ${remoteIP} still active ${plugins.prettyMs(now - record.outgoingClosedTime)} after outgoing closed.`);
|
|
767
1208
|
this.cleanupConnection(record, 'parity_check');
|
|
768
1209
|
}
|
|
769
1210
|
|
|
770
|
-
//
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1211
|
+
// Skip inactivity check if disabled
|
|
1212
|
+
if (!this.settings.disableInactivityCheck) {
|
|
1213
|
+
// Inactivity check - use protocol-specific values
|
|
1214
|
+
let inactivityThreshold = Math.floor(Math.random() * (1800000 - 1200000 + 1)) + 1200000; // random between 20 and 30 minutes
|
|
1215
|
+
|
|
1216
|
+
// Set protocol-specific inactivity thresholds
|
|
1217
|
+
if (record.protocolType === 'http' && record.isPooledConnection) {
|
|
1218
|
+
inactivityThreshold = this.settings.httpKeepAliveTimeout || 1200000; // 20 minutes for pooled HTTP
|
|
1219
|
+
} else if (record.protocolType === 'websocket') {
|
|
1220
|
+
inactivityThreshold = this.settings.wsConnectionTimeout || 14400000; // 4 hours for WebSocket
|
|
1221
|
+
} else if (record.protocolType === 'http') {
|
|
1222
|
+
inactivityThreshold = this.settings.httpConnectionTimeout || 1800000; // 30 minutes for HTTP
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const inactivityTime = now - record.lastActivity;
|
|
1226
|
+
if (inactivityTime > inactivityThreshold && !record.connectionClosed) {
|
|
1227
|
+
console.log(`[${id}] Inactivity check: No activity on ${record.protocolType} connection from ${record.remoteIP} for ${plugins.prettyMs(inactivityTime)}.`);
|
|
1228
|
+
this.cleanupConnection(record, 'inactivity');
|
|
1229
|
+
}
|
|
777
1230
|
}
|
|
778
1231
|
}
|
|
779
1232
|
|
|
1233
|
+
// Log detailed stats periodically
|
|
780
1234
|
console.log(
|
|
781
|
-
`
|
|
782
|
-
`
|
|
783
|
-
`
|
|
784
|
-
`
|
|
1235
|
+
`Active connections: ${this.connectionRecords.size}. ` +
|
|
1236
|
+
`Types: HTTP=${httpConnections}, WS=${wsConnections}, TLS=${tlsConnections}, Unknown=${unknownConnections}, Pooled=${pooledConnections}. ` +
|
|
1237
|
+
`Longest running: IN=${plugins.prettyMs(maxIncoming)}, OUT=${plugins.prettyMs(maxOutgoing)}. ` +
|
|
1238
|
+
`Termination stats: ${JSON.stringify({IN: this.terminationStats.incoming, OUT: this.terminationStats.outgoing})}`
|
|
785
1239
|
);
|
|
786
|
-
},
|
|
1240
|
+
}, this.settings.inactivityCheckInterval || 30000);
|
|
1241
|
+
|
|
1242
|
+
// Make sure the interval doesn't keep the process alive
|
|
1243
|
+
if (this.connectionLogger.unref) {
|
|
1244
|
+
this.connectionLogger.unref();
|
|
1245
|
+
}
|
|
787
1246
|
}
|
|
788
1247
|
|
|
1248
|
+
/**
|
|
1249
|
+
* Gracefully shut down the proxy
|
|
1250
|
+
*/
|
|
789
1251
|
public async stop() {
|
|
790
1252
|
console.log("PortProxy shutting down...");
|
|
791
1253
|
this.isShuttingDown = true;
|
|
@@ -874,13 +1336,11 @@ export class PortProxy {
|
|
|
874
1336
|
}
|
|
875
1337
|
}
|
|
876
1338
|
|
|
877
|
-
// Clear
|
|
1339
|
+
// Clear all tracking maps
|
|
878
1340
|
this.connectionRecords.clear();
|
|
879
|
-
|
|
880
|
-
// Clear the domain target indices map to prevent memory leaks
|
|
881
1341
|
this.domainTargetIndices.clear();
|
|
882
|
-
|
|
883
|
-
|
|
1342
|
+
this.connectionsByIP.clear();
|
|
1343
|
+
this.connectionRateByIP.clear();
|
|
884
1344
|
this.netServers = [];
|
|
885
1345
|
|
|
886
1346
|
// Reset termination stats
|