@push.rocks/smartproxy 3.20.2 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,8 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions {
22
22
  maxConnectionLifetime?: number; // (ms) force cleanup of long-lived connections
23
23
  globalPortRanges: Array<{ from: number; to: number }>; // Global allowed port ranges
24
24
  forwardAllGlobalRanges?: boolean; // When true, forwards all connections on global port ranges to the global targetIP
25
+ gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
26
+ initialDataTimeout?: number; // (ms) timeout for receiving initial data, useful for chained proxies
25
27
  }
26
28
 
27
29
  /**
@@ -93,9 +95,12 @@ interface IConnectionRecord {
93
95
  incomingStartTime: number;
94
96
  outgoingStartTime?: number;
95
97
  outgoingClosedTime?: number;
96
- lockedDomain?: string; // New field to lock this connection to the initial SNI
98
+ lockedDomain?: string; // Field to lock this connection to the initial SNI
97
99
  connectionClosed: boolean;
98
100
  cleanupTimer?: NodeJS.Timeout; // Timer to force cleanup after max lifetime/inactivity
101
+ cleanupInitiated: boolean; // Flag to track if cleanup has been initiated but not completed
102
+ id: string; // Unique identifier for the connection
103
+ lastActivity: number; // Timestamp of last activity on either socket
99
104
  }
100
105
 
101
106
  // Helper: Check if a port falls within any of the given port ranges.
@@ -128,12 +133,18 @@ const isGlobIPAllowed = (ip: string, allowed: string[], blocked: string[] = []):
128
133
  return isAllowed(ip, allowed);
129
134
  };
130
135
 
136
+ // Helper: Generate a unique ID for a connection
137
+ const generateConnectionId = (): string => {
138
+ return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
139
+ };
140
+
131
141
  export class PortProxy {
132
142
  private netServers: plugins.net.Server[] = [];
133
143
  settings: IPortProxySettings;
134
144
  // Unified record tracking each connection pair.
135
- private connectionRecords: Set<IConnectionRecord> = new Set();
145
+ private connectionRecords: Map<string, IConnectionRecord> = new Map();
136
146
  private connectionLogger: NodeJS.Timeout | null = null;
147
+ private isShuttingDown: boolean = false;
137
148
 
138
149
  // Map to track round robin indices for each domain config.
139
150
  private domainTargetIndices: Map<IDomainConfig, number> = new Map();
@@ -151,7 +162,11 @@ export class PortProxy {
151
162
  ...settingsArg,
152
163
  targetIP: settingsArg.targetIP || 'localhost',
153
164
  maxConnectionLifetime: settingsArg.maxConnectionLifetime || 600000,
165
+ gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000,
154
166
  };
167
+
168
+ // Debug logging for constructor settings
169
+ console.log(`PortProxy initialized with targetIP: ${this.settings.targetIP}, toPort: ${this.settings.toPort}, fromPort: ${this.settings.fromPort}, sniEnabled: ${this.settings.sniEnabled}`);
155
170
  }
156
171
 
157
172
  private incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void {
@@ -159,30 +174,77 @@ export class PortProxy {
159
174
  }
160
175
 
161
176
  /**
162
- * Cleans up a connection record if not already cleaned up.
163
- * Destroys both incoming and outgoing sockets, clears timers, and removes the record.
164
- * Logs the cleanup event.
177
+ * Initiates the cleanup process for a connection.
178
+ * Sets the flag to prevent duplicate cleanup attempts and schedules actual cleanup.
179
+ */
180
+ private initiateCleanup(record: IConnectionRecord, reason: string = 'normal'): void {
181
+ if (record.cleanupInitiated) return;
182
+
183
+ record.cleanupInitiated = true;
184
+ const remoteIP = record.incoming.remoteAddress || 'unknown';
185
+ console.log(`Initiating cleanup for connection ${record.id} from ${remoteIP} (reason: ${reason})`);
186
+
187
+ // Execute cleanup immediately to prevent lingering connections
188
+ this.executeCleanup(record);
189
+ }
190
+
191
+ /**
192
+ * Executes the actual cleanup of a connection.
193
+ * Destroys sockets, clears timers, and removes the record.
165
194
  */
166
- private cleanupConnection(record: IConnectionRecord, special: boolean = false): void {
167
- if (!record.connectionClosed) {
168
- record.connectionClosed = true;
169
- if (record.cleanupTimer) {
170
- clearTimeout(record.cleanupTimer);
195
+ private executeCleanup(record: IConnectionRecord): void {
196
+ if (record.connectionClosed) return;
197
+
198
+ record.connectionClosed = true;
199
+ const remoteIP = record.incoming.remoteAddress || 'unknown';
200
+
201
+ if (record.cleanupTimer) {
202
+ clearTimeout(record.cleanupTimer);
203
+ record.cleanupTimer = undefined;
204
+ }
205
+
206
+ // End the sockets first to allow for graceful closure
207
+ try {
208
+ if (!record.incoming.destroyed) {
209
+ record.incoming.end();
210
+ // Set a safety timeout to force destroy if end doesn't complete
211
+ setTimeout(() => {
212
+ if (!record.incoming.destroyed) {
213
+ console.log(`Forcing destruction of incoming socket for ${remoteIP}`);
214
+ record.incoming.destroy();
215
+ }
216
+ }, 1000);
171
217
  }
218
+ } catch (err) {
219
+ console.error(`Error ending incoming socket for ${remoteIP}:`, err);
172
220
  if (!record.incoming.destroyed) {
173
221
  record.incoming.destroy();
174
222
  }
223
+ }
224
+
225
+ try {
175
226
  if (record.outgoing && !record.outgoing.destroyed) {
176
- record.outgoing.destroy();
227
+ record.outgoing.end();
228
+ // Set a safety timeout to force destroy if end doesn't complete
229
+ setTimeout(() => {
230
+ if (record.outgoing && !record.outgoing.destroyed) {
231
+ console.log(`Forcing destruction of outgoing socket for ${remoteIP}`);
232
+ record.outgoing.destroy();
233
+ }
234
+ }, 1000);
177
235
  }
178
- this.connectionRecords.delete(record);
179
- const remoteIP = record.incoming.remoteAddress || 'unknown';
180
- if (special) {
181
- console.log(`Special parity cleanup: Connection from ${remoteIP} cleaned up due to duration difference.`);
182
- } else {
183
- console.log(`Connection from ${remoteIP} terminated. Active connections: ${this.connectionRecords.size}`);
236
+ } catch (err) {
237
+ console.error(`Error ending outgoing socket for ${remoteIP}:`, err);
238
+ if (record.outgoing && !record.outgoing.destroyed) {
239
+ record.outgoing.destroy();
184
240
  }
185
241
  }
242
+
243
+ // Remove the record after a delay to ensure all events have propagated
244
+ setTimeout(() => {
245
+ this.connectionRecords.delete(record.id);
246
+ console.log(`Connection ${record.id} from ${remoteIP} fully cleaned up. Active connections: ${this.connectionRecords.size}`);
247
+ }, 2000);
186
248
  }
187
249
 
188
250
  private getTargetIP(domainConfig: IDomainConfig): string {
@@ -195,27 +257,60 @@ export class PortProxy {
195
257
  return this.settings.targetIP!;
196
258
  }
197
259
 
260
+ /**
261
+ * Updates the last activity timestamp for a connection record
262
+ */
263
+ private updateActivity(record: IConnectionRecord): void {
264
+ record.lastActivity = Date.now();
265
+
266
+ // Reset the inactivity timer if one is set
267
+ if (this.settings.maxConnectionLifetime && record.cleanupTimer) {
268
+ clearTimeout(record.cleanupTimer);
269
+
270
+ // Set a new cleanup timer
271
+ record.cleanupTimer = setTimeout(() => {
272
+ const now = Date.now();
273
+ const inactivityTime = now - record.lastActivity;
274
+ const remoteIP = record.incoming.remoteAddress || 'unknown';
275
+ console.log(`Connection ${record.id} from ${remoteIP} exceeded max lifetime or inactivity period (${inactivityTime}ms), forcing cleanup.`);
276
+ this.initiateCleanup(record, 'timeout');
277
+ }, this.settings.maxConnectionLifetime);
278
+ }
279
+ }
280
+
198
281
  public async start() {
199
282
  // Define a unified connection handler for all listening ports.
200
283
  const connectionHandler = (socket: plugins.net.Socket) => {
284
+ if (this.isShuttingDown) {
285
+ socket.end();
286
+ socket.destroy();
287
+ return;
288
+ }
289
+
201
290
  const remoteIP = socket.remoteAddress || '';
202
291
  const localPort = socket.localPort; // The port on which this connection was accepted.
292
+
293
+ const connectionId = generateConnectionId();
203
294
  const connectionRecord: IConnectionRecord = {
295
+ id: connectionId,
204
296
  incoming: socket,
205
297
  outgoing: null,
206
298
  incomingStartTime: Date.now(),
299
+ lastActivity: Date.now(),
207
300
  connectionClosed: false,
301
+ cleanupInitiated: false
208
302
  };
209
- this.connectionRecords.add(connectionRecord);
210
- console.log(`New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionRecords.size}`);
303
+
304
+ this.connectionRecords.set(connectionId, connectionRecord);
305
+ console.log(`New connection ${connectionId} from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionRecords.size}`);
211
306
 
212
307
  let initialDataReceived = false;
213
308
  let incomingTerminationReason: string | null = null;
214
309
  let outgoingTerminationReason: string | null = null;
215
310
 
216
311
  // Local cleanup function that delegates to the class method.
217
- const cleanupOnce = () => {
218
- this.cleanupConnection(connectionRecord);
312
+ const initiateCleanupOnce = (reason: string = 'normal') => {
313
+ this.initiateCleanup(connectionRecord, reason);
219
314
  };
220
315
 
221
316
  // Helper to reject an incoming connection.
@@ -226,14 +321,55 @@ export class PortProxy {
226
321
  incomingTerminationReason = reason;
227
322
  this.incrementTerminationStat('incoming', reason);
228
323
  }
229
- cleanupOnce();
324
+ initiateCleanupOnce(reason);
230
325
  };
231
326
 
327
+ // Set an initial timeout only if SNI is enabled or this is not a chained proxy
328
+ // For chained proxies, we need to allow more time for data to flow through
329
+ const initialTimeoutMs = this.settings.initialDataTimeout ||
330
+ (this.settings.sniEnabled ? 15000 : 0); // Increased timeout for SNI, disabled for non-SNI by default
331
+
332
+ let initialTimeout: NodeJS.Timeout | null = null;
333
+
334
+ if (initialTimeoutMs > 0) {
335
+ console.log(`Setting initial data timeout of ${initialTimeoutMs}ms for connection from ${remoteIP}`);
336
+ initialTimeout = setTimeout(() => {
337
+ if (!initialDataReceived) {
338
+ console.log(`Initial connection timeout for ${remoteIP} (no data received after ${initialTimeoutMs}ms)`);
339
+ if (incomingTerminationReason === null) {
340
+ incomingTerminationReason = 'initial_timeout';
341
+ this.incrementTerminationStat('incoming', 'initial_timeout');
342
+ }
343
+ initiateCleanupOnce('initial_timeout');
344
+ }
345
+ }, initialTimeoutMs);
346
+ } else {
347
+ console.log(`No initial timeout set for connection from ${remoteIP} (likely chained proxy)`);
348
+ // Mark as received immediately if we're not waiting for data
349
+ initialDataReceived = true;
350
+ }
351
+
232
352
  socket.on('error', (err: Error) => {
233
353
  const errorMessage = initialDataReceived
234
354
  ? `(Immediate) Incoming socket error from ${remoteIP}: ${err.message}`
235
355
  : `(Premature) Incoming socket error from ${remoteIP} before data received: ${err.message}`;
236
356
  console.log(errorMessage);
357
+
358
+ // Clear the initial timeout if it exists
359
+ if (initialTimeout) {
360
+ clearTimeout(initialTimeout);
361
+ initialTimeout = null;
362
+ }
363
+
364
+ // For premature errors, we need to handle them explicitly
365
+ // since the standard error handlers might not be set up yet
366
+ if (!initialDataReceived) {
367
+ if (incomingTerminationReason === null) {
368
+ incomingTerminationReason = 'premature_error';
369
+ this.incrementTerminationStat('incoming', 'premature_error');
370
+ }
371
+ initiateCleanupOnce('premature_error');
372
+ }
237
373
  });
238
374
 
239
375
  const handleError = (side: 'incoming' | 'outgoing') => (err: Error) => {
@@ -242,9 +378,13 @@ export class PortProxy {
242
378
  if (code === 'ECONNRESET') {
243
379
  reason = 'econnreset';
244
380
  console.log(`ECONNRESET on ${side} side from ${remoteIP}: ${err.message}`);
381
+ } else if (code === 'ECONNREFUSED') {
382
+ reason = 'econnrefused';
383
+ console.log(`ECONNREFUSED on ${side} side from ${remoteIP}: ${err.message}`);
245
384
  } else {
246
385
  console.log(`Error on ${side} side from ${remoteIP}: ${err.message}`);
247
386
  }
387
+
248
388
  if (side === 'incoming' && incomingTerminationReason === null) {
249
389
  incomingTerminationReason = reason;
250
390
  this.incrementTerminationStat('incoming', reason);
@@ -252,11 +392,13 @@ export class PortProxy {
252
392
  outgoingTerminationReason = reason;
253
393
  this.incrementTerminationStat('outgoing', reason);
254
394
  }
255
- cleanupOnce();
395
+
396
+ initiateCleanupOnce(reason);
256
397
  };
257
398
 
258
399
  const handleClose = (side: 'incoming' | 'outgoing') => () => {
259
400
  console.log(`Connection closed on ${side} side from ${remoteIP}`);
401
+
260
402
  if (side === 'incoming' && incomingTerminationReason === null) {
261
403
  incomingTerminationReason = 'normal';
262
404
  this.incrementTerminationStat('incoming', 'normal');
@@ -265,8 +407,24 @@ export class PortProxy {
265
407
  this.incrementTerminationStat('outgoing', 'normal');
266
408
  // Record the time when outgoing socket closed.
267
409
  connectionRecord.outgoingClosedTime = Date.now();
410
+
411
+ // If incoming is still active but outgoing closed, set a shorter timeout
412
+ if (!connectionRecord.incoming.destroyed) {
413
+ console.log(`Outgoing socket closed but incoming still active for ${remoteIP}. Setting cleanup timeout.`);
414
+ setTimeout(() => {
415
+ if (!connectionRecord.connectionClosed && !connectionRecord.incoming.destroyed) {
416
+ console.log(`Incoming socket still active ${Date.now() - connectionRecord.outgoingClosedTime!}ms after outgoing closed for ${remoteIP}. Cleaning up.`);
417
+ initiateCleanupOnce('outgoing_closed_timeout');
418
+ }
419
+ }, 10000); // 10 second timeout instead of waiting for the next parity check
420
+ }
421
+ }
422
+
423
+ // If both sides are closed/destroyed, clean up
424
+ if ((side === 'incoming' && connectionRecord.outgoing?.destroyed) ||
425
+ (side === 'outgoing' && connectionRecord.incoming.destroyed)) {
426
+ initiateCleanupOnce('both_closed');
268
427
  }
269
- cleanupOnce();
270
428
  };
271
429
 
272
430
  /**
@@ -274,9 +432,14 @@ export class PortProxy {
274
432
  * @param serverName - The SNI hostname (unused when forcedDomain is provided).
275
433
  * @param initialChunk - Optional initial data chunk.
276
434
  * @param forcedDomain - If provided, overrides SNI/domain lookup (used for port-based routing).
277
- * @param overridePort - If provided, use this port for the outgoing connection (typically the same as the incoming port).
435
+ * @param overridePort - If provided, use this port for the outgoing connection.
278
436
  */
279
437
  const setupConnection = (serverName: string, initialChunk?: Buffer, forcedDomain?: IDomainConfig, overridePort?: number) => {
438
+ // Clear the initial timeout since we've received data
439
+ if (initialTimeout) {
440
+ clearTimeout(initialTimeout);
441
+ }
442
+
280
443
  // If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
281
444
  const domainConfig = forcedDomain
282
445
  ? forcedDomain
@@ -285,22 +448,34 @@ export class PortProxy {
285
448
  ) : undefined);
286
449
 
287
450
  // Effective IP check: merge allowed IPs with default allowed, and remove blocked IPs.
451
+ // In a chained proxy, relax IP validation unless explicitly configured
452
+ // If this is the first proxy in the chain, normal validation applies
288
453
  if (domainConfig) {
289
- const effectiveAllowedIPs: string[] = [
290
- ...domainConfig.allowedIPs,
291
- ...(this.settings.defaultAllowedIPs || [])
292
- ];
293
- const effectiveBlockedIPs: string[] = [
294
- ...(domainConfig.blockedIPs || []),
295
- ...(this.settings.defaultBlockedIPs || [])
296
- ];
297
- if (!isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
298
- return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${domainConfig.domains.join(', ')}`);
454
+ // Has specific domain config - check IP restrictions only if allowedIPs is non-empty
455
+ if (domainConfig.allowedIPs.length > 0) {
456
+ const effectiveAllowedIPs: string[] = [
457
+ ...domainConfig.allowedIPs,
458
+ ...(this.settings.defaultAllowedIPs || [])
459
+ ];
460
+ const effectiveBlockedIPs: string[] = [
461
+ ...(domainConfig.blockedIPs || []),
462
+ ...(this.settings.defaultBlockedIPs || [])
463
+ ];
464
+ if (!isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
465
+ return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${domainConfig.domains.join(', ')}`);
466
+ }
467
+ } else {
468
+ console.log(`Domain config for ${domainConfig.domains.join(', ')} has empty allowedIPs, skipping IP validation`);
299
469
  }
300
- } else if (this.settings.defaultAllowedIPs) {
470
+ } else if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) {
471
+ // No domain config but has default IP restrictions
301
472
  if (!isGlobIPAllowed(remoteIP, this.settings.defaultAllowedIPs, this.settings.defaultBlockedIPs || [])) {
302
473
  return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed by default allowed list`);
303
474
  }
475
+ } else {
476
+ // No domain config and no default allowed IPs
477
+ // In a chained proxy setup, we'll allow this connection
478
+ console.log(`No specific IP restrictions found for ${remoteIP}. Allowing connection in potential chained proxy setup.`);
304
479
  }
305
480
 
306
481
  const targetHost = domainConfig ? this.getTargetIP(domainConfig) : this.settings.targetIP!;
@@ -312,85 +487,124 @@ export class PortProxy {
312
487
  connectionOptions.localAddress = remoteIP.replace('::ffff:', '');
313
488
  }
314
489
 
490
+ // Add explicit connection timeout and error handling
491
+ let connectionTimeout: NodeJS.Timeout | null = null;
492
+ let connectionSucceeded = false;
493
+
494
+ // Set connection timeout - longer for chained proxies
495
+ connectionTimeout = setTimeout(() => {
496
+ if (!connectionSucceeded) {
497
+ console.log(`Connection timeout connecting to ${targetHost}:${connectionOptions.port} for ${remoteIP}`);
498
+ if (outgoingTerminationReason === null) {
499
+ outgoingTerminationReason = 'connection_timeout';
500
+ this.incrementTerminationStat('outgoing', 'connection_timeout');
501
+ }
502
+ initiateCleanupOnce('connection_timeout');
503
+ }
504
+ }, 10000); // Increased from 5s to 10s to accommodate chained proxies
505
+
506
+ console.log(`Attempting to connect to ${targetHost}:${connectionOptions.port} for client ${remoteIP}...`);
507
+
508
+ // Create the target socket
315
509
  const targetSocket = plugins.net.connect(connectionOptions);
316
510
  connectionRecord.outgoing = targetSocket;
317
- connectionRecord.outgoingStartTime = Date.now();
511
+
512
+ // Handle successful connection
513
+ targetSocket.once('connect', () => {
514
+ connectionSucceeded = true;
515
+ if (connectionTimeout) {
516
+ clearTimeout(connectionTimeout);
517
+ connectionTimeout = null;
518
+ }
519
+
520
+ connectionRecord.outgoingStartTime = Date.now();
521
+ console.log(
522
+ `Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
523
+ `${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}`
524
+ );
318
525
 
319
- console.log(
320
- `Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
321
- `${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}`
322
- );
526
+ // Setup data flow after confirmed connection
527
+ setupDataFlow(targetSocket, initialChunk);
528
+ });
323
529
 
530
+ // Handle connection errors early
531
+ targetSocket.once('error', (err) => {
532
+ if (!connectionSucceeded) {
533
+ // This is an initial connection error
534
+ console.log(`Failed to connect to ${targetHost}:${connectionOptions.port} for ${remoteIP}: ${err.message}`);
535
+ if (connectionTimeout) {
536
+ clearTimeout(connectionTimeout);
537
+ connectionTimeout = null;
538
+ }
539
+ if (outgoingTerminationReason === null) {
540
+ outgoingTerminationReason = 'connection_failed';
541
+ this.incrementTerminationStat('outgoing', 'connection_failed');
542
+ }
543
+ initiateCleanupOnce('connection_failed');
544
+ }
545
+ // Other errors will be handled by the main error handler
546
+ });
547
+ };
548
+
549
+ /**
550
+ * Sets up the data flow between sockets after successful connection
551
+ */
552
+ const setupDataFlow = (targetSocket: plugins.net.Socket, initialChunk?: Buffer) => {
324
553
  if (initialChunk) {
325
554
  socket.unshift(initialChunk);
326
555
  }
556
+
557
+ // Set appropriate timeouts for both sockets
327
558
  socket.setTimeout(120000);
559
+ targetSocket.setTimeout(120000);
560
+
561
+ // Set up the pipe in both directions
328
562
  socket.pipe(targetSocket);
329
563
  targetSocket.pipe(socket);
330
564
 
331
- // Attach error and close handlers.
565
+ // Attach error and close handlers
332
566
  socket.on('error', handleError('incoming'));
333
567
  targetSocket.on('error', handleError('outgoing'));
334
568
  socket.on('close', handleClose('incoming'));
335
569
  targetSocket.on('close', handleClose('outgoing'));
570
+
571
+ // Handle timeout events
336
572
  socket.on('timeout', () => {
337
573
  console.log(`Timeout on incoming side from ${remoteIP}`);
338
574
  if (incomingTerminationReason === null) {
339
575
  incomingTerminationReason = 'timeout';
340
576
  this.incrementTerminationStat('incoming', 'timeout');
341
577
  }
342
- cleanupOnce();
578
+ initiateCleanupOnce('timeout');
343
579
  });
580
+
344
581
  targetSocket.on('timeout', () => {
345
582
  console.log(`Timeout on outgoing side from ${remoteIP}`);
346
583
  if (outgoingTerminationReason === null) {
347
584
  outgoingTerminationReason = 'timeout';
348
585
  this.incrementTerminationStat('outgoing', 'timeout');
349
586
  }
350
- cleanupOnce();
587
+ initiateCleanupOnce('timeout');
351
588
  });
589
+
352
590
  socket.on('end', handleClose('incoming'));
353
591
  targetSocket.on('end', handleClose('outgoing'));
354
592
 
355
- // Initialize a cleanup timer for max connection lifetime.
593
+ // Track activity for both sockets to reset inactivity timers
594
+ socket.on('data', (data) => {
595
+ this.updateActivity(connectionRecord);
596
+ });
597
+
598
+ targetSocket.on('data', (data) => {
599
+ this.updateActivity(connectionRecord);
600
+ });
601
+
602
+ // Initialize a cleanup timer for max connection lifetime
356
603
  if (this.settings.maxConnectionLifetime) {
357
- // Flags to track if data was seen from each side.
358
- let incomingActive = false;
359
- let outgoingActive = false;
360
- const resetCleanupTimer = () => {
361
- if (this.settings.maxConnectionLifetime) {
362
- if (connectionRecord.cleanupTimer) {
363
- clearTimeout(connectionRecord.cleanupTimer);
364
- }
365
- connectionRecord.cleanupTimer = setTimeout(() => {
366
- console.log(`Connection from ${remoteIP} exceeded max lifetime with inactivity (${this.settings.maxConnectionLifetime}ms), forcing cleanup.`);
367
- cleanupOnce();
368
- }, this.settings.maxConnectionLifetime);
369
- }
370
- };
371
-
372
- resetCleanupTimer();
373
-
374
- // Only reset the timer if outgoing socket is still active.
375
- socket.on('data', () => {
376
- incomingActive = true;
377
- // Check if outgoing has not been closed before resetting timer.
378
- if (!connectionRecord.outgoingClosedTime && incomingActive && outgoingActive) {
379
- resetCleanupTimer();
380
- incomingActive = false;
381
- outgoingActive = false;
382
- }
383
- });
384
- targetSocket.on('data', () => {
385
- // If outgoing is closed, do not set outgoingActive.
386
- if (connectionRecord.outgoingClosedTime) return;
387
- outgoingActive = true;
388
- if (incomingActive && outgoingActive) {
389
- resetCleanupTimer();
390
- incomingActive = false;
391
- outgoingActive = false;
392
- }
393
- });
604
+ connectionRecord.cleanupTimer = setTimeout(() => {
605
+ console.log(`Connection from ${remoteIP} exceeded max lifetime (${this.settings.maxConnectionLifetime}ms), forcing cleanup.`);
606
+ initiateCleanupOnce('max_lifetime');
607
+ }, this.settings.maxConnectionLifetime);
394
608
  }
395
609
  };
396
610
 
@@ -401,6 +615,7 @@ export class PortProxy {
401
615
  if (this.settings.defaultAllowedIPs && !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
402
616
  console.log(`Connection from ${remoteIP} rejected: IP ${remoteIP} not allowed in global default allowed list.`);
403
617
  socket.end();
618
+ initiateCleanupOnce('rejected');
404
619
  return;
405
620
  }
406
621
  console.log(`Port-based connection from ${remoteIP} on port ${localPort} forwarded to global target IP ${this.settings.targetIP}.`);
@@ -429,6 +644,7 @@ export class PortProxy {
429
644
  if (!isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
430
645
  console.log(`Connection from ${remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join(', ')} on port ${localPort}.`);
431
646
  socket.end();
647
+ initiateCleanupOnce('rejected');
432
648
  return;
433
649
  }
434
650
  console.log(`Port-based connection from ${remoteIP} on port ${localPort} matched domain ${forcedDomain.domains.join(', ')}.`);
@@ -441,39 +657,80 @@ export class PortProxy {
441
657
 
442
658
  // --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabled) ---
443
659
  if (this.settings.sniEnabled) {
444
- socket.setTimeout(5000, () => {
445
- console.log(`Initial data timeout for ${remoteIP}`);
446
- socket.end();
447
- cleanupOnce();
448
- });
449
-
660
+ // If using SNI, we need to wait for data to establish the connection
661
+ if (initialDataReceived) {
662
+ console.log(`Initial data already marked as received for ${remoteIP}, but SNI is enabled. This is unexpected.`);
663
+ }
664
+
665
+ initialDataReceived = false;
666
+
667
+ console.log(`Waiting for TLS ClientHello from ${remoteIP} to extract SNI...`);
450
668
  socket.once('data', (chunk: Buffer) => {
451
- socket.setTimeout(0);
669
+ if (initialTimeout) {
670
+ clearTimeout(initialTimeout);
671
+ initialTimeout = null;
672
+ }
673
+
452
674
  initialDataReceived = true;
453
- const serverName = extractSNI(chunk) || '';
675
+ console.log(`Received initial data from ${remoteIP}, length: ${chunk.length} bytes`);
676
+
677
+ let serverName = '';
678
+ try {
679
+ // Only try to extract SNI if the chunk looks like a TLS ClientHello
680
+ if (chunk.length > 5 && chunk.readUInt8(0) === 22) {
681
+ serverName = extractSNI(chunk) || '';
682
+ console.log(`Extracted SNI: "${serverName}" from connection ${remoteIP}`);
683
+ } else {
684
+ console.log(`Data from ${remoteIP} doesn't appear to be a TLS ClientHello. First byte: ${chunk.length > 0 ? chunk.readUInt8(0) : 'N/A'}`);
685
+ }
686
+ } catch (err) {
687
+ console.log(`Error extracting SNI from chunk: ${err}. Proceeding without SNI.`);
688
+ }
689
+
454
690
  // Lock the connection to the negotiated SNI.
455
691
  connectionRecord.lockedDomain = serverName;
456
- console.log(`Received connection from ${remoteIP} with SNI: ${serverName}`);
692
+
457
693
  // Delay adding the renegotiation listener until the next tick,
458
694
  // so the initial ClientHello is not reprocessed.
459
695
  setImmediate(() => {
460
696
  socket.on('data', (renegChunk: Buffer) => {
461
697
  if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
462
- const newSNI = extractSNI(renegChunk);
463
- if (newSNI && newSNI !== connectionRecord.lockedDomain) {
464
- console.log(`Rehandshake detected with different SNI: ${newSNI} vs locked ${connectionRecord.lockedDomain}. Terminating connection.`);
465
- cleanupOnce();
698
+ try {
699
+ // Try to extract SNI from potential renegotiation
700
+ const newSNI = extractSNI(renegChunk);
701
+ if (newSNI && newSNI !== connectionRecord.lockedDomain) {
702
+ console.log(`Rehandshake detected with different SNI: ${newSNI} vs locked ${connectionRecord.lockedDomain}. Terminating connection.`);
703
+ initiateCleanupOnce('sni_mismatch');
704
+ } else if (newSNI) {
705
+ console.log(`Rehandshake detected with same SNI: ${newSNI}. Allowing.`);
706
+ }
707
+ } catch (err) {
708
+ console.log(`Error processing potential renegotiation: ${err}. Allowing connection to continue.`);
466
709
  }
467
710
  }
468
711
  });
469
712
  });
713
+
470
714
  setupConnection(serverName, chunk);
471
715
  });
472
716
  } else {
717
+ // Non-SNI mode: we can proceed immediately without waiting for data
718
+ if (initialTimeout) {
719
+ clearTimeout(initialTimeout);
720
+ initialTimeout = null;
721
+ }
722
+
473
723
  initialDataReceived = true;
474
- if (!this.settings.defaultAllowedIPs || !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
475
- return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`);
724
+ console.log(`SNI disabled for connection from ${remoteIP}, proceeding directly to connection setup`);
725
+
726
+ // Check IP restrictions only if explicitly configured
727
+ if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) {
728
+ if (!isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
729
+ return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for non-SNI connection`);
730
+ }
476
731
  }
732
+
733
+ // Proceed with connection setup
477
734
  setupConnection('');
478
735
  }
479
736
  };
@@ -507,23 +764,48 @@ export class PortProxy {
507
764
  this.netServers.push(server);
508
765
  }
509
766
 
510
- // Log active connection count, longest running durations, and run parity checks every 10 seconds.
767
+ // Log active connection count, run parity checks, and check for connection issues every 10 seconds.
511
768
  this.connectionLogger = setInterval(() => {
769
+ if (this.isShuttingDown) return;
770
+
512
771
  const now = Date.now();
513
772
  let maxIncoming = 0;
514
773
  let maxOutgoing = 0;
515
- for (const record of this.connectionRecords) {
774
+
775
+ // Create a copy of the keys to avoid modification during iteration
776
+ const connectionIds = [...this.connectionRecords.keys()];
777
+
778
+ for (const id of connectionIds) {
779
+ const record = this.connectionRecords.get(id);
780
+ if (!record) continue;
781
+
516
782
  maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
517
783
  if (record.outgoingStartTime) {
518
784
  maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
519
785
  }
520
- // Parity check: if outgoing socket closed and incoming remains active for >1 minute, trigger special cleanup.
521
- if (record.outgoingClosedTime && !record.incoming.destroyed && (now - record.outgoingClosedTime > 60000)) {
786
+
787
+ // Parity check: if outgoing socket closed and incoming remains active for >30 seconds, trigger cleanup
788
+ if (record.outgoingClosedTime &&
789
+ !record.incoming.destroyed &&
790
+ !record.connectionClosed &&
791
+ !record.cleanupInitiated &&
792
+ (now - record.outgoingClosedTime > 30000)) {
793
+ const remoteIP = record.incoming.remoteAddress || 'unknown';
794
+ console.log(`Parity check triggered: Incoming socket for ${remoteIP} has been active >30s after outgoing closed.`);
795
+ this.initiateCleanup(record, 'parity_check');
796
+ }
797
+
798
+ // Inactivity check: if no activity for a long time but sockets still open
799
+ const inactivityTime = now - record.lastActivity;
800
+ if (inactivityTime > 180000 && // 3 minutes
801
+ !record.connectionClosed &&
802
+ !record.cleanupInitiated) {
522
803
  const remoteIP = record.incoming.remoteAddress || 'unknown';
523
- console.log(`Parity check triggered: Incoming socket for ${remoteIP} has been active >1 minute after outgoing closed.`);
524
- this.cleanupConnection(record, true);
804
+ console.log(`Inactivity check triggered: No activity on connection from ${remoteIP} for ${plugins.prettyMs(inactivityTime)}.`);
805
+ this.initiateCleanup(record, 'inactivity');
525
806
  }
526
807
  }
808
+
527
809
  console.log(
528
810
  `(Interval Log) Active connections: ${this.connectionRecords.size}. ` +
529
811
  `Longest running incoming: ${plugins.prettyMs(maxIncoming)}, outgoing: ${plugins.prettyMs(maxOutgoing)}. ` +
@@ -534,17 +816,69 @@ export class PortProxy {
534
816
  }
535
817
 
536
818
  public async stop() {
537
- // Close all servers.
538
- const closePromises: Promise<void>[] = this.netServers.map(
819
+ console.log("PortProxy shutting down...");
820
+ this.isShuttingDown = true;
821
+
822
+ // Stop accepting new connections
823
+ const closeServerPromises: Promise<void>[] = this.netServers.map(
539
824
  server =>
540
825
  new Promise<void>((resolve) => {
541
826
  server.close(() => resolve());
542
827
  })
543
828
  );
829
+
830
+ // Stop the connection logger
544
831
  if (this.connectionLogger) {
545
832
  clearInterval(this.connectionLogger);
546
833
  this.connectionLogger = null;
547
834
  }
548
- await Promise.all(closePromises);
835
+
836
+ // Wait for servers to close
837
+ await Promise.all(closeServerPromises);
838
+ console.log("All servers closed. Cleaning up active connections...");
839
+
840
+ // Gracefully close active connections
841
+ const connectionIds = [...this.connectionRecords.keys()];
842
+ console.log(`Cleaning up ${connectionIds.length} active connections...`);
843
+
844
+ for (const id of connectionIds) {
845
+ const record = this.connectionRecords.get(id);
846
+ if (record && !record.connectionClosed && !record.cleanupInitiated) {
847
+ this.initiateCleanup(record, 'shutdown');
848
+ }
849
+ }
850
+
851
+ // Wait for graceful shutdown or timeout
852
+ const shutdownTimeout = this.settings.gracefulShutdownTimeout || 30000;
853
+ await new Promise<void>((resolve) => {
854
+ const checkInterval = setInterval(() => {
855
+ if (this.connectionRecords.size === 0) {
856
+ clearInterval(checkInterval);
857
+ resolve();
858
+ }
859
+ }, 1000);
860
+
861
+ // Force resolve after timeout
862
+ setTimeout(() => {
863
+ clearInterval(checkInterval);
864
+ if (this.connectionRecords.size > 0) {
865
+ console.log(`Forcing shutdown with ${this.connectionRecords.size} connections still active`);
866
+
867
+ // Force destroy any remaining connections
868
+ for (const record of this.connectionRecords.values()) {
869
+ if (!record.incoming.destroyed) {
870
+ record.incoming.destroy();
871
+ }
872
+ if (record.outgoing && !record.outgoing.destroyed) {
873
+ record.outgoing.destroy();
874
+ }
875
+ }
876
+ this.connectionRecords.clear();
877
+ }
878
+ resolve();
879
+ }, shutdownTimeout);
880
+ });
881
+
882
+ console.log("PortProxy shutdown complete.");
549
883
  }
550
884
  }