@push.rocks/smartproxy 3.23.0 → 3.24.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.
@@ -23,6 +23,13 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions {
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
25
  gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
26
+
27
+ // Socket optimization settings
28
+ noDelay?: boolean; // Disable Nagle's algorithm (default: true)
29
+ keepAlive?: boolean; // Enable TCP keepalive (default: true)
30
+ keepAliveInitialDelay?: number; // Initial delay before sending keepalive probes (ms)
31
+ maxPendingDataSize?: number; // Maximum bytes to buffer during connection setup
32
+ initialDataTimeout?: number; // Timeout for initial data/SNI (ms)
26
33
  }
27
34
 
28
35
  /**
@@ -99,6 +106,8 @@ interface IConnectionRecord {
99
106
  connectionClosed: boolean; // Flag to prevent multiple cleanup attempts
100
107
  cleanupTimer?: NodeJS.Timeout; // Timer for max lifetime/inactivity
101
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
102
111
  }
103
112
 
104
113
  // Helper: Check if a port falls within any of the given port ranges
@@ -160,6 +169,11 @@ export class PortProxy {
160
169
  targetIP: settingsArg.targetIP || 'localhost',
161
170
  maxConnectionLifetime: settingsArg.maxConnectionLifetime || 600000,
162
171
  gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000,
172
+ noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true,
173
+ keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true,
174
+ keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 60000, // 1 minute
175
+ maxPendingDataSize: settingsArg.maxPendingDataSize || 1024 * 1024, // 1MB
176
+ initialDataTimeout: settingsArg.initialDataTimeout || 5000 // 5 seconds
163
177
  };
164
178
  }
165
179
 
@@ -186,16 +200,29 @@ export class PortProxy {
186
200
  if (!record.incoming.destroyed) {
187
201
  // Try graceful shutdown first, then force destroy after a short timeout
188
202
  record.incoming.end();
189
- setTimeout(() => {
190
- if (record && !record.incoming.destroyed) {
191
- record.incoming.destroy();
203
+ const incomingTimeout = setTimeout(() => {
204
+ try {
205
+ if (record && !record.incoming.destroyed) {
206
+ record.incoming.destroy();
207
+ }
208
+ } catch (err) {
209
+ console.log(`Error destroying incoming socket: ${err}`);
192
210
  }
193
211
  }, 1000);
212
+
213
+ // Ensure the timeout doesn't block Node from exiting
214
+ if (incomingTimeout.unref) {
215
+ incomingTimeout.unref();
216
+ }
194
217
  }
195
218
  } catch (err) {
196
219
  console.log(`Error closing incoming socket: ${err}`);
197
- if (!record.incoming.destroyed) {
198
- record.incoming.destroy();
220
+ try {
221
+ if (!record.incoming.destroyed) {
222
+ record.incoming.destroy();
223
+ }
224
+ } catch (destroyErr) {
225
+ console.log(`Error destroying incoming socket: ${destroyErr}`);
199
226
  }
200
227
  }
201
228
 
@@ -203,19 +230,36 @@ export class PortProxy {
203
230
  if (record.outgoing && !record.outgoing.destroyed) {
204
231
  // Try graceful shutdown first, then force destroy after a short timeout
205
232
  record.outgoing.end();
206
- setTimeout(() => {
207
- if (record && record.outgoing && !record.outgoing.destroyed) {
208
- record.outgoing.destroy();
233
+ const outgoingTimeout = setTimeout(() => {
234
+ try {
235
+ if (record && record.outgoing && !record.outgoing.destroyed) {
236
+ record.outgoing.destroy();
237
+ }
238
+ } catch (err) {
239
+ console.log(`Error destroying outgoing socket: ${err}`);
209
240
  }
210
241
  }, 1000);
242
+
243
+ // Ensure the timeout doesn't block Node from exiting
244
+ if (outgoingTimeout.unref) {
245
+ outgoingTimeout.unref();
246
+ }
211
247
  }
212
248
  } catch (err) {
213
249
  console.log(`Error closing outgoing socket: ${err}`);
214
- if (record.outgoing && !record.outgoing.destroyed) {
215
- record.outgoing.destroy();
250
+ try {
251
+ if (record.outgoing && !record.outgoing.destroyed) {
252
+ record.outgoing.destroy();
253
+ }
254
+ } catch (destroyErr) {
255
+ console.log(`Error destroying outgoing socket: ${destroyErr}`);
216
256
  }
217
257
  }
218
258
 
259
+ // Clear pendingData to avoid memory leaks
260
+ record.pendingData = [];
261
+ record.pendingDataSize = 0;
262
+
219
263
  // Remove the record from the tracking map
220
264
  this.connectionRecords.delete(record.id);
221
265
 
@@ -239,6 +283,11 @@ export class PortProxy {
239
283
  }
240
284
 
241
285
  public async start() {
286
+ // Don't start if already shutting down
287
+ if (this.isShuttingDown) {
288
+ console.log("Cannot start PortProxy while it's shutting down");
289
+ return;
290
+ }
242
291
  // Define a unified connection handler for all listening ports.
243
292
  const connectionHandler = (socket: plugins.net.Socket) => {
244
293
  if (this.isShuttingDown) {
@@ -250,6 +299,10 @@ export class PortProxy {
250
299
  const remoteIP = socket.remoteAddress || '';
251
300
  const localPort = socket.localPort; // The port on which this connection was accepted.
252
301
 
302
+ // Apply socket optimizations
303
+ socket.setNoDelay(this.settings.noDelay);
304
+ socket.setKeepAlive(this.settings.keepAlive, this.settings.keepAliveInitialDelay);
305
+
253
306
  const connectionId = generateConnectionId();
254
307
  const connectionRecord: IConnectionRecord = {
255
308
  id: connectionId,
@@ -257,7 +310,9 @@ export class PortProxy {
257
310
  outgoing: null,
258
311
  incomingStartTime: Date.now(),
259
312
  lastActivity: Date.now(),
260
- connectionClosed: false
313
+ connectionClosed: false,
314
+ pendingData: [], // Initialize buffer for pending data
315
+ pendingDataSize: 0 // Initialize buffer size counter
261
316
  };
262
317
  this.connectionRecords.set(connectionId, connectionRecord);
263
318
 
@@ -294,11 +349,15 @@ export class PortProxy {
294
349
  if (this.settings.sniEnabled) {
295
350
  initialTimeout = setTimeout(() => {
296
351
  if (!initialDataReceived) {
297
- console.log(`Initial data timeout for ${remoteIP}`);
352
+ console.log(`Initial data timeout (${this.settings.initialDataTimeout}ms) for connection from ${remoteIP} on port ${localPort}`);
353
+ if (incomingTerminationReason === null) {
354
+ incomingTerminationReason = 'initial_timeout';
355
+ this.incrementTerminationStat('incoming', 'initial_timeout');
356
+ }
298
357
  socket.end();
299
358
  cleanupOnce();
300
359
  }
301
- }, 5000);
360
+ }, this.settings.initialDataTimeout || 5000);
302
361
  } else {
303
362
  initialDataReceived = true;
304
363
  }
@@ -391,29 +450,83 @@ export class PortProxy {
391
450
  connectionOptions.localAddress = remoteIP.replace('::ffff:', '');
392
451
  }
393
452
 
394
- // Create the target socket and immediately set up data piping
453
+ // Pause the incoming socket to prevent buffer overflows
454
+ socket.pause();
455
+
456
+ // Temporary handler to collect data during connection setup
457
+ const tempDataHandler = (chunk: Buffer) => {
458
+ // Check if adding this chunk would exceed the buffer limit
459
+ const newSize = connectionRecord.pendingDataSize + chunk.length;
460
+
461
+ if (this.settings.maxPendingDataSize && newSize > this.settings.maxPendingDataSize) {
462
+ console.log(`Buffer limit exceeded for connection from ${remoteIP}: ${newSize} bytes > ${this.settings.maxPendingDataSize} bytes`);
463
+ socket.end(); // Gracefully close the socket
464
+ return initiateCleanupOnce('buffer_limit_exceeded');
465
+ }
466
+
467
+ // Buffer the chunk and update the size counter
468
+ connectionRecord.pendingData.push(Buffer.from(chunk));
469
+ connectionRecord.pendingDataSize = newSize;
470
+ this.updateActivity(connectionRecord);
471
+ };
472
+
473
+ // Add the temp handler to capture all incoming data during connection setup
474
+ socket.on('data', tempDataHandler);
475
+
476
+ // Add initial chunk to pending data if present
477
+ if (initialChunk) {
478
+ connectionRecord.pendingData.push(Buffer.from(initialChunk));
479
+ connectionRecord.pendingDataSize = initialChunk.length;
480
+ }
481
+
482
+ // Create the target socket but don't set up piping immediately
395
483
  const targetSocket = plugins.net.connect(connectionOptions);
396
484
  connectionRecord.outgoing = targetSocket;
397
485
  connectionRecord.outgoingStartTime = Date.now();
398
486
 
399
- // Set up the pipe immediately to ensure data flows without delay
400
- if (initialChunk) {
401
- socket.unshift(initialChunk);
402
- }
487
+ // Apply socket optimizations
488
+ targetSocket.setNoDelay(this.settings.noDelay);
489
+ targetSocket.setKeepAlive(this.settings.keepAlive, this.settings.keepAliveInitialDelay);
403
490
 
404
- socket.pipe(targetSocket);
405
- targetSocket.pipe(socket);
491
+ // Setup specific error handler for connection phase
492
+ targetSocket.once('error', (err) => {
493
+ // This handler runs only once during the initial connection phase
494
+ const code = (err as any).code;
495
+ console.log(`Connection setup error to ${targetHost}:${connectionOptions.port}: ${err.message} (${code})`);
496
+
497
+ // Resume the incoming socket to prevent it from hanging
498
+ socket.resume();
499
+
500
+ if (code === 'ECONNREFUSED') {
501
+ console.log(`Target ${targetHost}:${connectionOptions.port} refused connection`);
502
+ } else if (code === 'ETIMEDOUT') {
503
+ console.log(`Connection to ${targetHost}:${connectionOptions.port} timed out`);
504
+ } else if (code === 'ECONNRESET') {
505
+ console.log(`Connection to ${targetHost}:${connectionOptions.port} was reset`);
506
+ } else if (code === 'EHOSTUNREACH') {
507
+ console.log(`Host ${targetHost} is unreachable`);
508
+ }
509
+
510
+ // Clear any existing error handler after connection phase
511
+ targetSocket.removeAllListeners('error');
512
+
513
+ // Re-add the normal error handler for established connections
514
+ targetSocket.on('error', handleError('outgoing'));
515
+
516
+ if (outgoingTerminationReason === null) {
517
+ outgoingTerminationReason = 'connection_failed';
518
+ this.incrementTerminationStat('outgoing', 'connection_failed');
519
+ }
520
+
521
+ // Clean up the connection
522
+ initiateCleanupOnce(`connection_failed_${code}`);
523
+ });
406
524
 
407
- console.log(
408
- `Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
409
- `${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}`
410
- );
411
-
412
- // Add appropriate handlers for connection management
413
- socket.on('error', handleError('incoming'));
414
- targetSocket.on('error', handleError('outgoing'));
415
- socket.on('close', handleClose('incoming'));
525
+ // Setup close handler
416
526
  targetSocket.on('close', handleClose('outgoing'));
527
+ socket.on('close', handleClose('incoming'));
528
+
529
+ // Handle timeouts
417
530
  socket.on('timeout', () => {
418
531
  console.log(`Timeout on incoming side from ${remoteIP}`);
419
532
  if (incomingTerminationReason === null) {
@@ -435,13 +548,81 @@ export class PortProxy {
435
548
  socket.setTimeout(120000);
436
549
  targetSocket.setTimeout(120000);
437
550
 
438
- // Update activity for both sockets
439
- socket.on('data', () => {
440
- connectionRecord.lastActivity = Date.now();
441
- });
442
-
443
- targetSocket.on('data', () => {
444
- connectionRecord.lastActivity = Date.now();
551
+ // Wait for the outgoing connection to be ready before setting up piping
552
+ targetSocket.once('connect', () => {
553
+ // Clear the initial connection error handler
554
+ targetSocket.removeAllListeners('error');
555
+
556
+ // Add the normal error handler for established connections
557
+ targetSocket.on('error', handleError('outgoing'));
558
+
559
+ // Remove temporary data handler
560
+ socket.removeListener('data', tempDataHandler);
561
+
562
+ // Flush all pending data to target
563
+ if (connectionRecord.pendingData.length > 0) {
564
+ const combinedData = Buffer.concat(connectionRecord.pendingData);
565
+ targetSocket.write(combinedData, (err) => {
566
+ if (err) {
567
+ console.log(`Error writing pending data to target: ${err.message}`);
568
+ return initiateCleanupOnce('write_error');
569
+ }
570
+
571
+ // Now set up piping for future data and resume the socket
572
+ socket.pipe(targetSocket);
573
+ targetSocket.pipe(socket);
574
+ socket.resume(); // Resume the socket after piping is established
575
+
576
+ console.log(
577
+ `Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
578
+ `${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}`
579
+ );
580
+ });
581
+ } else {
582
+ // No pending data, so just set up piping
583
+ socket.pipe(targetSocket);
584
+ targetSocket.pipe(socket);
585
+ socket.resume(); // Resume the socket after piping is established
586
+
587
+ console.log(
588
+ `Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
589
+ `${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}`
590
+ );
591
+ }
592
+
593
+ // Clear the buffer now that we've processed it
594
+ connectionRecord.pendingData = [];
595
+ connectionRecord.pendingDataSize = 0;
596
+
597
+ // Set up activity tracking
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)
608
+ if (serverName) {
609
+ socket.on('data', (renegChunk: Buffer) => {
610
+ if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
611
+ try {
612
+ // Try to extract SNI from potential renegotiation
613
+ const newSNI = extractSNI(renegChunk);
614
+ if (newSNI && newSNI !== connectionRecord.lockedDomain) {
615
+ console.log(`Rehandshake detected with different SNI: ${newSNI} vs locked ${connectionRecord.lockedDomain}. Terminating connection.`);
616
+ initiateCleanupOnce('sni_mismatch');
617
+ } else if (newSNI) {
618
+ console.log(`Rehandshake detected with same SNI: ${newSNI}. Allowing.`);
619
+ }
620
+ } catch (err) {
621
+ console.log(`Error processing potential renegotiation: ${err}. Allowing connection to continue.`);
622
+ }
623
+ }
624
+ });
625
+ }
445
626
  });
446
627
 
447
628
  // Initialize a cleanup timer for max connection lifetime
@@ -514,27 +695,6 @@ export class PortProxy {
514
695
  connectionRecord.lockedDomain = serverName;
515
696
  console.log(`Received connection from ${remoteIP} with SNI: ${serverName}`);
516
697
 
517
- // Delay adding the renegotiation listener until the next tick,
518
- // so the initial ClientHello is not reprocessed.
519
- setImmediate(() => {
520
- socket.on('data', (renegChunk: Buffer) => {
521
- if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
522
- try {
523
- // Try to extract SNI from potential renegotiation
524
- const newSNI = extractSNI(renegChunk);
525
- if (newSNI && newSNI !== connectionRecord.lockedDomain) {
526
- console.log(`Rehandshake detected with different SNI: ${newSNI} vs locked ${connectionRecord.lockedDomain}. Terminating connection.`);
527
- initiateCleanupOnce('sni_mismatch');
528
- } else if (newSNI) {
529
- console.log(`Rehandshake detected with same SNI: ${newSNI}. Allowing.`);
530
- }
531
- } catch (err) {
532
- console.log(`Error processing potential renegotiation: ${err}. Allowing connection to continue.`);
533
- }
534
- }
535
- });
536
- });
537
-
538
698
  setupConnection(serverName, chunk);
539
699
  });
540
700
  } else {
@@ -577,6 +737,8 @@ export class PortProxy {
577
737
 
578
738
  // Log active connection count, longest running durations, and run parity checks every 10 seconds.
579
739
  this.connectionLogger = setInterval(() => {
740
+ // Immediately return if shutting down
741
+ if (this.isShuttingDown) return;
580
742
  if (this.isShuttingDown) return;
581
743
 
582
744
  const now = Date.now();
@@ -632,7 +794,16 @@ export class PortProxy {
632
794
  const closeServerPromises: Promise<void>[] = this.netServers.map(
633
795
  server =>
634
796
  new Promise<void>((resolve) => {
635
- server.close(() => resolve());
797
+ if (!server.listening) {
798
+ resolve();
799
+ return;
800
+ }
801
+ server.close((err) => {
802
+ if (err) {
803
+ console.log(`Error closing server: ${err.message}`);
804
+ }
805
+ resolve();
806
+ });
636
807
  })
637
808
  );
638
809
 
@@ -646,47 +817,77 @@ export class PortProxy {
646
817
  await Promise.all(closeServerPromises);
647
818
  console.log("All servers closed. Cleaning up active connections...");
648
819
 
649
- // Clean up active connections
820
+ // Force destroy all active connections immediately
650
821
  const connectionIds = [...this.connectionRecords.keys()];
651
822
  console.log(`Cleaning up ${connectionIds.length} active connections...`);
652
823
 
824
+ // First pass: End all connections gracefully
653
825
  for (const id of connectionIds) {
654
826
  const record = this.connectionRecords.get(id);
655
- if (record && !record.connectionClosed) {
656
- this.cleanupConnection(record, 'shutdown');
827
+ if (record) {
828
+ try {
829
+ // Clear any timers
830
+ if (record.cleanupTimer) {
831
+ clearTimeout(record.cleanupTimer);
832
+ record.cleanupTimer = undefined;
833
+ }
834
+
835
+ // End sockets gracefully
836
+ if (record.incoming && !record.incoming.destroyed) {
837
+ record.incoming.end();
838
+ }
839
+
840
+ if (record.outgoing && !record.outgoing.destroyed) {
841
+ record.outgoing.end();
842
+ }
843
+ } catch (err) {
844
+ console.log(`Error during graceful connection end for ${id}: ${err}`);
845
+ }
657
846
  }
658
847
  }
659
848
 
660
- // Wait for graceful shutdown or timeout
661
- const shutdownTimeout = this.settings.gracefulShutdownTimeout || 30000;
662
- await new Promise<void>((resolve) => {
663
- const checkInterval = setInterval(() => {
664
- if (this.connectionRecords.size === 0) {
665
- clearInterval(checkInterval);
666
- resolve(); // lets resolve here as early as we reach 0 remaining connections
667
- }
668
- }, 1000);
669
-
670
- // Force resolve after timeout
671
- setTimeout(() => {
672
- clearInterval(checkInterval);
673
- if (this.connectionRecords.size > 0) {
674
- console.log(`Forcing shutdown with ${this.connectionRecords.size} connections still active`);
675
-
676
- // Force destroy any remaining connections
677
- for (const record of this.connectionRecords.values()) {
849
+ // Short delay to allow graceful ends to process
850
+ await new Promise(resolve => setTimeout(resolve, 100));
851
+
852
+ // Second pass: Force destroy everything
853
+ for (const id of connectionIds) {
854
+ const record = this.connectionRecords.get(id);
855
+ if (record) {
856
+ try {
857
+ // Remove all listeners to prevent memory leaks
858
+ if (record.incoming) {
859
+ record.incoming.removeAllListeners();
678
860
  if (!record.incoming.destroyed) {
679
861
  record.incoming.destroy();
680
862
  }
681
- if (record.outgoing && !record.outgoing.destroyed) {
863
+ }
864
+
865
+ if (record.outgoing) {
866
+ record.outgoing.removeAllListeners();
867
+ if (!record.outgoing.destroyed) {
682
868
  record.outgoing.destroy();
683
869
  }
684
870
  }
685
- this.connectionRecords.clear();
871
+ } catch (err) {
872
+ console.log(`Error during forced connection destruction for ${id}: ${err}`);
686
873
  }
687
- resolve();
688
- }, shutdownTimeout);
689
- });
874
+ }
875
+ }
876
+
877
+ // Clear the connection records map
878
+ this.connectionRecords.clear();
879
+
880
+ // Clear the domain target indices map to prevent memory leaks
881
+ this.domainTargetIndices.clear();
882
+
883
+ // Clear any servers array
884
+ this.netServers = [];
885
+
886
+ // Reset termination stats
887
+ this.terminationStats = {
888
+ incoming: {},
889
+ outgoing: {}
890
+ };
690
891
 
691
892
  console.log("PortProxy shutdown complete.");
692
893
  }