@push.rocks/smartproxy 3.20.1 → 3.21.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,7 @@ 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
25
26
  }
26
27
 
27
28
  /**
@@ -93,9 +94,12 @@ interface IConnectionRecord {
93
94
  incomingStartTime: number;
94
95
  outgoingStartTime?: number;
95
96
  outgoingClosedTime?: number;
96
- lockedDomain?: string; // New field to lock this connection to the initial SNI
97
+ lockedDomain?: string; // Field to lock this connection to the initial SNI
97
98
  connectionClosed: boolean;
98
99
  cleanupTimer?: NodeJS.Timeout; // Timer to force cleanup after max lifetime/inactivity
100
+ cleanupInitiated: boolean; // Flag to track if cleanup has been initiated but not completed
101
+ id: string; // Unique identifier for the connection
102
+ lastActivity: number; // Timestamp of last activity on either socket
99
103
  }
100
104
 
101
105
  // Helper: Check if a port falls within any of the given port ranges.
@@ -128,12 +132,18 @@ const isGlobIPAllowed = (ip: string, allowed: string[], blocked: string[] = []):
128
132
  return isAllowed(ip, allowed);
129
133
  };
130
134
 
135
+ // Helper: Generate a unique ID for a connection
136
+ const generateConnectionId = (): string => {
137
+ return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
138
+ };
139
+
131
140
  export class PortProxy {
132
141
  private netServers: plugins.net.Server[] = [];
133
142
  settings: IPortProxySettings;
134
143
  // Unified record tracking each connection pair.
135
- private connectionRecords: Set<IConnectionRecord> = new Set();
144
+ private connectionRecords: Map<string, IConnectionRecord> = new Map();
136
145
  private connectionLogger: NodeJS.Timeout | null = null;
146
+ private isShuttingDown: boolean = false;
137
147
 
138
148
  // Map to track round robin indices for each domain config.
139
149
  private domainTargetIndices: Map<IDomainConfig, number> = new Map();
@@ -151,6 +161,7 @@ export class PortProxy {
151
161
  ...settingsArg,
152
162
  targetIP: settingsArg.targetIP || 'localhost',
153
163
  maxConnectionLifetime: settingsArg.maxConnectionLifetime || 600000,
164
+ gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000,
154
165
  };
155
166
  }
156
167
 
@@ -159,30 +170,77 @@ export class PortProxy {
159
170
  }
160
171
 
161
172
  /**
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.
173
+ * Initiates the cleanup process for a connection.
174
+ * Sets the flag to prevent duplicate cleanup attempts and schedules actual cleanup.
165
175
  */
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);
176
+ private initiateCleanup(record: IConnectionRecord, reason: string = 'normal'): void {
177
+ if (record.cleanupInitiated) return;
178
+
179
+ record.cleanupInitiated = true;
180
+ const remoteIP = record.incoming.remoteAddress || 'unknown';
181
+ console.log(`Initiating cleanup for connection ${record.id} from ${remoteIP} (reason: ${reason})`);
182
+
183
+ // Execute cleanup immediately to prevent lingering connections
184
+ this.executeCleanup(record);
185
+ }
186
+
187
+ /**
188
+ * Executes the actual cleanup of a connection.
189
+ * Destroys sockets, clears timers, and removes the record.
190
+ */
191
+ private executeCleanup(record: IConnectionRecord): void {
192
+ if (record.connectionClosed) return;
193
+
194
+ record.connectionClosed = true;
195
+ const remoteIP = record.incoming.remoteAddress || 'unknown';
196
+
197
+ if (record.cleanupTimer) {
198
+ clearTimeout(record.cleanupTimer);
199
+ record.cleanupTimer = undefined;
200
+ }
201
+
202
+ // End the sockets first to allow for graceful closure
203
+ try {
204
+ if (!record.incoming.destroyed) {
205
+ record.incoming.end();
206
+ // Set a safety timeout to force destroy if end doesn't complete
207
+ setTimeout(() => {
208
+ if (!record.incoming.destroyed) {
209
+ console.log(`Forcing destruction of incoming socket for ${remoteIP}`);
210
+ record.incoming.destroy();
211
+ }
212
+ }, 1000);
171
213
  }
214
+ } catch (err) {
215
+ console.error(`Error ending incoming socket for ${remoteIP}:`, err);
172
216
  if (!record.incoming.destroyed) {
173
217
  record.incoming.destroy();
174
218
  }
219
+ }
220
+
221
+ try {
175
222
  if (record.outgoing && !record.outgoing.destroyed) {
176
- record.outgoing.destroy();
223
+ record.outgoing.end();
224
+ // Set a safety timeout to force destroy if end doesn't complete
225
+ setTimeout(() => {
226
+ if (record.outgoing && !record.outgoing.destroyed) {
227
+ console.log(`Forcing destruction of outgoing socket for ${remoteIP}`);
228
+ record.outgoing.destroy();
229
+ }
230
+ }, 1000);
177
231
  }
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}`);
232
+ } catch (err) {
233
+ console.error(`Error ending outgoing socket for ${remoteIP}:`, err);
234
+ if (record.outgoing && !record.outgoing.destroyed) {
235
+ record.outgoing.destroy();
184
236
  }
185
237
  }
238
+
239
+ // Remove the record after a delay to ensure all events have propagated
240
+ setTimeout(() => {
241
+ this.connectionRecords.delete(record.id);
242
+ console.log(`Connection ${record.id} from ${remoteIP} fully cleaned up. Active connections: ${this.connectionRecords.size}`);
243
+ }, 2000);
186
244
  }
187
245
 
188
246
  private getTargetIP(domainConfig: IDomainConfig): string {
@@ -195,27 +253,60 @@ export class PortProxy {
195
253
  return this.settings.targetIP!;
196
254
  }
197
255
 
256
+ /**
257
+ * Updates the last activity timestamp for a connection record
258
+ */
259
+ private updateActivity(record: IConnectionRecord): void {
260
+ record.lastActivity = Date.now();
261
+
262
+ // Reset the inactivity timer if one is set
263
+ if (this.settings.maxConnectionLifetime && record.cleanupTimer) {
264
+ clearTimeout(record.cleanupTimer);
265
+
266
+ // Set a new cleanup timer
267
+ record.cleanupTimer = setTimeout(() => {
268
+ const now = Date.now();
269
+ const inactivityTime = now - record.lastActivity;
270
+ const remoteIP = record.incoming.remoteAddress || 'unknown';
271
+ console.log(`Connection ${record.id} from ${remoteIP} exceeded max lifetime or inactivity period (${inactivityTime}ms), forcing cleanup.`);
272
+ this.initiateCleanup(record, 'timeout');
273
+ }, this.settings.maxConnectionLifetime);
274
+ }
275
+ }
276
+
198
277
  public async start() {
199
278
  // Define a unified connection handler for all listening ports.
200
279
  const connectionHandler = (socket: plugins.net.Socket) => {
280
+ if (this.isShuttingDown) {
281
+ socket.end();
282
+ socket.destroy();
283
+ return;
284
+ }
285
+
201
286
  const remoteIP = socket.remoteAddress || '';
202
287
  const localPort = socket.localPort; // The port on which this connection was accepted.
288
+
289
+ const connectionId = generateConnectionId();
203
290
  const connectionRecord: IConnectionRecord = {
291
+ id: connectionId,
204
292
  incoming: socket,
205
293
  outgoing: null,
206
294
  incomingStartTime: Date.now(),
295
+ lastActivity: Date.now(),
207
296
  connectionClosed: false,
297
+ cleanupInitiated: false
208
298
  };
209
- this.connectionRecords.add(connectionRecord);
210
- console.log(`New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionRecords.size}`);
299
+
300
+ this.connectionRecords.set(connectionId, connectionRecord);
301
+ console.log(`New connection ${connectionId} from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionRecords.size}`);
211
302
 
212
303
  let initialDataReceived = false;
213
304
  let incomingTerminationReason: string | null = null;
214
305
  let outgoingTerminationReason: string | null = null;
215
306
 
216
307
  // Local cleanup function that delegates to the class method.
217
- const cleanupOnce = () => {
218
- this.cleanupConnection(connectionRecord);
308
+ const initiateCleanupOnce = (reason: string = 'normal') => {
309
+ this.initiateCleanup(connectionRecord, reason);
219
310
  };
220
311
 
221
312
  // Helper to reject an incoming connection.
@@ -226,14 +317,31 @@ export class PortProxy {
226
317
  incomingTerminationReason = reason;
227
318
  this.incrementTerminationStat('incoming', reason);
228
319
  }
229
- cleanupOnce();
320
+ initiateCleanupOnce(reason);
230
321
  };
231
322
 
323
+ // Set an initial timeout immediately
324
+ const initialTimeout = setTimeout(() => {
325
+ if (!initialDataReceived) {
326
+ console.log(`Initial connection timeout for ${remoteIP} (no data received)`);
327
+ if (incomingTerminationReason === null) {
328
+ incomingTerminationReason = 'initial_timeout';
329
+ this.incrementTerminationStat('incoming', 'initial_timeout');
330
+ }
331
+ initiateCleanupOnce('initial_timeout');
332
+ }
333
+ }, 5000);
334
+
232
335
  socket.on('error', (err: Error) => {
233
336
  const errorMessage = initialDataReceived
234
337
  ? `(Immediate) Incoming socket error from ${remoteIP}: ${err.message}`
235
338
  : `(Premature) Incoming socket error from ${remoteIP} before data received: ${err.message}`;
236
339
  console.log(errorMessage);
340
+
341
+ // Clear the initial timeout if it exists
342
+ if (initialTimeout) {
343
+ clearTimeout(initialTimeout);
344
+ }
237
345
  });
238
346
 
239
347
  const handleError = (side: 'incoming' | 'outgoing') => (err: Error) => {
@@ -242,9 +350,13 @@ export class PortProxy {
242
350
  if (code === 'ECONNRESET') {
243
351
  reason = 'econnreset';
244
352
  console.log(`ECONNRESET on ${side} side from ${remoteIP}: ${err.message}`);
353
+ } else if (code === 'ECONNREFUSED') {
354
+ reason = 'econnrefused';
355
+ console.log(`ECONNREFUSED on ${side} side from ${remoteIP}: ${err.message}`);
245
356
  } else {
246
357
  console.log(`Error on ${side} side from ${remoteIP}: ${err.message}`);
247
358
  }
359
+
248
360
  if (side === 'incoming' && incomingTerminationReason === null) {
249
361
  incomingTerminationReason = reason;
250
362
  this.incrementTerminationStat('incoming', reason);
@@ -252,11 +364,13 @@ export class PortProxy {
252
364
  outgoingTerminationReason = reason;
253
365
  this.incrementTerminationStat('outgoing', reason);
254
366
  }
255
- cleanupOnce();
367
+
368
+ initiateCleanupOnce(reason);
256
369
  };
257
370
 
258
371
  const handleClose = (side: 'incoming' | 'outgoing') => () => {
259
372
  console.log(`Connection closed on ${side} side from ${remoteIP}`);
373
+
260
374
  if (side === 'incoming' && incomingTerminationReason === null) {
261
375
  incomingTerminationReason = 'normal';
262
376
  this.incrementTerminationStat('incoming', 'normal');
@@ -265,8 +379,24 @@ export class PortProxy {
265
379
  this.incrementTerminationStat('outgoing', 'normal');
266
380
  // Record the time when outgoing socket closed.
267
381
  connectionRecord.outgoingClosedTime = Date.now();
382
+
383
+ // If incoming is still active but outgoing closed, set a shorter timeout
384
+ if (!connectionRecord.incoming.destroyed) {
385
+ console.log(`Outgoing socket closed but incoming still active for ${remoteIP}. Setting cleanup timeout.`);
386
+ setTimeout(() => {
387
+ if (!connectionRecord.connectionClosed && !connectionRecord.incoming.destroyed) {
388
+ console.log(`Incoming socket still active ${Date.now() - connectionRecord.outgoingClosedTime!}ms after outgoing closed for ${remoteIP}. Cleaning up.`);
389
+ initiateCleanupOnce('outgoing_closed_timeout');
390
+ }
391
+ }, 10000); // 10 second timeout instead of waiting for the next parity check
392
+ }
393
+ }
394
+
395
+ // If both sides are closed/destroyed, clean up
396
+ if ((side === 'incoming' && connectionRecord.outgoing?.destroyed) ||
397
+ (side === 'outgoing' && connectionRecord.incoming.destroyed)) {
398
+ initiateCleanupOnce('both_closed');
268
399
  }
269
- cleanupOnce();
270
400
  };
271
401
 
272
402
  /**
@@ -274,9 +404,14 @@ export class PortProxy {
274
404
  * @param serverName - The SNI hostname (unused when forcedDomain is provided).
275
405
  * @param initialChunk - Optional initial data chunk.
276
406
  * @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).
407
+ * @param overridePort - If provided, use this port for the outgoing connection.
278
408
  */
279
409
  const setupConnection = (serverName: string, initialChunk?: Buffer, forcedDomain?: IDomainConfig, overridePort?: number) => {
410
+ // Clear the initial timeout since we've received data
411
+ if (initialTimeout) {
412
+ clearTimeout(initialTimeout);
413
+ }
414
+
280
415
  // If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
281
416
  const domainConfig = forcedDomain
282
417
  ? forcedDomain
@@ -297,10 +432,13 @@ export class PortProxy {
297
432
  if (!isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
298
433
  return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed for domain ${domainConfig.domains.join(', ')}`);
299
434
  }
300
- } else if (this.settings.defaultAllowedIPs) {
435
+ } else if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) {
301
436
  if (!isGlobIPAllowed(remoteIP, this.settings.defaultAllowedIPs, this.settings.defaultBlockedIPs || [])) {
302
437
  return rejectIncomingConnection('rejected', `Connection rejected: IP ${remoteIP} not allowed by default allowed list`);
303
438
  }
439
+ } else {
440
+ // No domain config and no default allowed IPs - reject the connection
441
+ return rejectIncomingConnection('no_config', `Connection rejected: No matching domain configuration or default allowed IPs for ${remoteIP}`);
304
442
  }
305
443
 
306
444
  const targetHost = domainConfig ? this.getTargetIP(domainConfig) : this.settings.targetIP!;
@@ -312,80 +450,124 @@ export class PortProxy {
312
450
  connectionOptions.localAddress = remoteIP.replace('::ffff:', '');
313
451
  }
314
452
 
453
+ // Add explicit connection timeout and error handling
454
+ let connectionTimeout: NodeJS.Timeout | null = null;
455
+ let connectionSucceeded = false;
456
+
457
+ // Set connection timeout
458
+ connectionTimeout = setTimeout(() => {
459
+ if (!connectionSucceeded) {
460
+ console.log(`Connection timeout connecting to ${targetHost}:${connectionOptions.port} for ${remoteIP}`);
461
+ if (outgoingTerminationReason === null) {
462
+ outgoingTerminationReason = 'connection_timeout';
463
+ this.incrementTerminationStat('outgoing', 'connection_timeout');
464
+ }
465
+ initiateCleanupOnce('connection_timeout');
466
+ }
467
+ }, 5000);
468
+
469
+ console.log(`Attempting to connect to ${targetHost}:${connectionOptions.port} for client ${remoteIP}...`);
470
+
471
+ // Create the target socket
315
472
  const targetSocket = plugins.net.connect(connectionOptions);
316
473
  connectionRecord.outgoing = targetSocket;
317
- connectionRecord.outgoingStartTime = Date.now();
474
+
475
+ // Handle successful connection
476
+ targetSocket.once('connect', () => {
477
+ connectionSucceeded = true;
478
+ if (connectionTimeout) {
479
+ clearTimeout(connectionTimeout);
480
+ connectionTimeout = null;
481
+ }
482
+
483
+ connectionRecord.outgoingStartTime = Date.now();
484
+ console.log(
485
+ `Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
486
+ `${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}`
487
+ );
318
488
 
319
- console.log(
320
- `Connection established: ${remoteIP} -> ${targetHost}:${connectionOptions.port}` +
321
- `${serverName ? ` (SNI: ${serverName})` : forcedDomain ? ` (Port-based for domain: ${forcedDomain.domains.join(', ')})` : ''}`
322
- );
489
+ // Setup data flow after confirmed connection
490
+ setupDataFlow(targetSocket, initialChunk);
491
+ });
492
+
493
+ // Handle connection errors early
494
+ targetSocket.once('error', (err) => {
495
+ if (!connectionSucceeded) {
496
+ // This is an initial connection error
497
+ console.log(`Failed to connect to ${targetHost}:${connectionOptions.port} for ${remoteIP}: ${err.message}`);
498
+ if (connectionTimeout) {
499
+ clearTimeout(connectionTimeout);
500
+ connectionTimeout = null;
501
+ }
502
+ if (outgoingTerminationReason === null) {
503
+ outgoingTerminationReason = 'connection_failed';
504
+ this.incrementTerminationStat('outgoing', 'connection_failed');
505
+ }
506
+ initiateCleanupOnce('connection_failed');
507
+ }
508
+ // Other errors will be handled by the main error handler
509
+ });
510
+ };
323
511
 
512
+ /**
513
+ * Sets up the data flow between sockets after successful connection
514
+ */
515
+ const setupDataFlow = (targetSocket: plugins.net.Socket, initialChunk?: Buffer) => {
324
516
  if (initialChunk) {
325
517
  socket.unshift(initialChunk);
326
518
  }
519
+
520
+ // Set appropriate timeouts for both sockets
327
521
  socket.setTimeout(120000);
522
+ targetSocket.setTimeout(120000);
523
+
524
+ // Set up the pipe in both directions
328
525
  socket.pipe(targetSocket);
329
526
  targetSocket.pipe(socket);
330
527
 
331
- // Attach error and close handlers.
528
+ // Attach error and close handlers
332
529
  socket.on('error', handleError('incoming'));
333
530
  targetSocket.on('error', handleError('outgoing'));
334
531
  socket.on('close', handleClose('incoming'));
335
532
  targetSocket.on('close', handleClose('outgoing'));
533
+
534
+ // Handle timeout events
336
535
  socket.on('timeout', () => {
337
536
  console.log(`Timeout on incoming side from ${remoteIP}`);
338
537
  if (incomingTerminationReason === null) {
339
538
  incomingTerminationReason = 'timeout';
340
539
  this.incrementTerminationStat('incoming', 'timeout');
341
540
  }
342
- cleanupOnce();
541
+ initiateCleanupOnce('timeout');
343
542
  });
543
+
344
544
  targetSocket.on('timeout', () => {
345
545
  console.log(`Timeout on outgoing side from ${remoteIP}`);
346
546
  if (outgoingTerminationReason === null) {
347
547
  outgoingTerminationReason = 'timeout';
348
548
  this.incrementTerminationStat('outgoing', 'timeout');
349
549
  }
350
- cleanupOnce();
550
+ initiateCleanupOnce('timeout');
351
551
  });
552
+
352
553
  socket.on('end', handleClose('incoming'));
353
554
  targetSocket.on('end', handleClose('outgoing'));
354
555
 
355
- // Initialize a cleanup timer for max connection lifetime.
356
- if (this.settings.maxConnectionLifetime) {
357
- let incomingActive = false;
358
- let outgoingActive = false;
359
- const resetCleanupTimer = () => {
360
- if (this.settings.maxConnectionLifetime) {
361
- if (connectionRecord.cleanupTimer) {
362
- clearTimeout(connectionRecord.cleanupTimer);
363
- }
364
- connectionRecord.cleanupTimer = setTimeout(() => {
365
- console.log(`Connection from ${remoteIP} exceeded max lifetime with inactivity (${this.settings.maxConnectionLifetime}ms), forcing cleanup.`);
366
- cleanupOnce();
367
- }, this.settings.maxConnectionLifetime);
368
- }
369
- };
370
-
371
- resetCleanupTimer();
556
+ // Track activity for both sockets to reset inactivity timers
557
+ socket.on('data', (data) => {
558
+ this.updateActivity(connectionRecord);
559
+ });
560
+
561
+ targetSocket.on('data', (data) => {
562
+ this.updateActivity(connectionRecord);
563
+ });
372
564
 
373
- socket.on('data', () => {
374
- incomingActive = true;
375
- if (incomingActive && outgoingActive) {
376
- resetCleanupTimer();
377
- incomingActive = false;
378
- outgoingActive = false;
379
- }
380
- });
381
- targetSocket.on('data', () => {
382
- outgoingActive = true;
383
- if (incomingActive && outgoingActive) {
384
- resetCleanupTimer();
385
- incomingActive = false;
386
- outgoingActive = false;
387
- }
388
- });
565
+ // Initialize a cleanup timer for max connection lifetime
566
+ if (this.settings.maxConnectionLifetime) {
567
+ connectionRecord.cleanupTimer = setTimeout(() => {
568
+ console.log(`Connection from ${remoteIP} exceeded max lifetime (${this.settings.maxConnectionLifetime}ms), forcing cleanup.`);
569
+ initiateCleanupOnce('max_lifetime');
570
+ }, this.settings.maxConnectionLifetime);
389
571
  }
390
572
  };
391
573
 
@@ -396,6 +578,7 @@ export class PortProxy {
396
578
  if (this.settings.defaultAllowedIPs && !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
397
579
  console.log(`Connection from ${remoteIP} rejected: IP ${remoteIP} not allowed in global default allowed list.`);
398
580
  socket.end();
581
+ initiateCleanupOnce('rejected');
399
582
  return;
400
583
  }
401
584
  console.log(`Port-based connection from ${remoteIP} on port ${localPort} forwarded to global target IP ${this.settings.targetIP}.`);
@@ -424,6 +607,7 @@ export class PortProxy {
424
607
  if (!isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
425
608
  console.log(`Connection from ${remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join(', ')} on port ${localPort}.`);
426
609
  socket.end();
610
+ initiateCleanupOnce('rejected');
427
611
  return;
428
612
  }
429
613
  console.log(`Port-based connection from ${remoteIP} on port ${localPort} matched domain ${forcedDomain.domains.join(', ')}.`);
@@ -436,32 +620,36 @@ export class PortProxy {
436
620
 
437
621
  // --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabled) ---
438
622
  if (this.settings.sniEnabled) {
439
- socket.setTimeout(5000, () => {
440
- console.log(`Initial data timeout for ${remoteIP}`);
441
- socket.end();
442
- cleanupOnce();
443
- });
623
+ initialDataReceived = false;
444
624
 
445
625
  socket.once('data', (chunk: Buffer) => {
446
- socket.setTimeout(0);
447
626
  initialDataReceived = true;
448
627
  const serverName = extractSNI(chunk) || '';
449
628
  // Lock the connection to the negotiated SNI.
450
629
  connectionRecord.lockedDomain = serverName;
451
630
  console.log(`Received connection from ${remoteIP} with SNI: ${serverName}`);
631
+
452
632
  // Delay adding the renegotiation listener until the next tick,
453
633
  // so the initial ClientHello is not reprocessed.
454
634
  setImmediate(() => {
455
635
  socket.on('data', (renegChunk: Buffer) => {
456
636
  if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
457
- const newSNI = extractSNI(renegChunk);
458
- if (newSNI && newSNI !== connectionRecord.lockedDomain) {
459
- console.log(`Rehandshake detected with different SNI: ${newSNI} vs locked ${connectionRecord.lockedDomain}. Terminating connection.`);
460
- cleanupOnce();
637
+ try {
638
+ // Try to extract SNI from potential renegotiation
639
+ const newSNI = extractSNI(renegChunk);
640
+ if (newSNI && newSNI !== connectionRecord.lockedDomain) {
641
+ console.log(`Rehandshake detected with different SNI: ${newSNI} vs locked ${connectionRecord.lockedDomain}. Terminating connection.`);
642
+ initiateCleanupOnce('sni_mismatch');
643
+ } else if (newSNI) {
644
+ console.log(`Rehandshake detected with same SNI: ${newSNI}. Allowing.`);
645
+ }
646
+ } catch (err) {
647
+ console.log(`Error processing potential renegotiation: ${err}. Allowing connection to continue.`);
461
648
  }
462
649
  }
463
650
  });
464
651
  });
652
+
465
653
  setupConnection(serverName, chunk);
466
654
  });
467
655
  } else {
@@ -502,23 +690,48 @@ export class PortProxy {
502
690
  this.netServers.push(server);
503
691
  }
504
692
 
505
- // Log active connection count, longest running durations, and run parity checks every 10 seconds.
693
+ // Log active connection count, run parity checks, and check for connection issues every 10 seconds.
506
694
  this.connectionLogger = setInterval(() => {
695
+ if (this.isShuttingDown) return;
696
+
507
697
  const now = Date.now();
508
698
  let maxIncoming = 0;
509
699
  let maxOutgoing = 0;
510
- for (const record of this.connectionRecords) {
700
+
701
+ // Create a copy of the keys to avoid modification during iteration
702
+ const connectionIds = [...this.connectionRecords.keys()];
703
+
704
+ for (const id of connectionIds) {
705
+ const record = this.connectionRecords.get(id);
706
+ if (!record) continue;
707
+
511
708
  maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
512
709
  if (record.outgoingStartTime) {
513
710
  maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
514
711
  }
515
- // Parity check: if outgoing socket closed and incoming remains active for >1 minute, trigger special cleanup.
516
- if (record.outgoingClosedTime && !record.incoming.destroyed && (now - record.outgoingClosedTime > 60000)) {
712
+
713
+ // Parity check: if outgoing socket closed and incoming remains active for >30 seconds, trigger cleanup
714
+ if (record.outgoingClosedTime &&
715
+ !record.incoming.destroyed &&
716
+ !record.connectionClosed &&
717
+ !record.cleanupInitiated &&
718
+ (now - record.outgoingClosedTime > 30000)) {
719
+ const remoteIP = record.incoming.remoteAddress || 'unknown';
720
+ console.log(`Parity check triggered: Incoming socket for ${remoteIP} has been active >30s after outgoing closed.`);
721
+ this.initiateCleanup(record, 'parity_check');
722
+ }
723
+
724
+ // Inactivity check: if no activity for a long time but sockets still open
725
+ const inactivityTime = now - record.lastActivity;
726
+ if (inactivityTime > 180000 && // 3 minutes
727
+ !record.connectionClosed &&
728
+ !record.cleanupInitiated) {
517
729
  const remoteIP = record.incoming.remoteAddress || 'unknown';
518
- console.log(`Parity check triggered: Incoming socket for ${remoteIP} has been active >1 minute after outgoing closed.`);
519
- this.cleanupConnection(record, true);
730
+ console.log(`Inactivity check triggered: No activity on connection from ${remoteIP} for ${plugins.prettyMs(inactivityTime)}.`);
731
+ this.initiateCleanup(record, 'inactivity');
520
732
  }
521
733
  }
734
+
522
735
  console.log(
523
736
  `(Interval Log) Active connections: ${this.connectionRecords.size}. ` +
524
737
  `Longest running incoming: ${plugins.prettyMs(maxIncoming)}, outgoing: ${plugins.prettyMs(maxOutgoing)}. ` +
@@ -529,17 +742,69 @@ export class PortProxy {
529
742
  }
530
743
 
531
744
  public async stop() {
532
- // Close all servers.
533
- const closePromises: Promise<void>[] = this.netServers.map(
745
+ console.log("PortProxy shutting down...");
746
+ this.isShuttingDown = true;
747
+
748
+ // Stop accepting new connections
749
+ const closeServerPromises: Promise<void>[] = this.netServers.map(
534
750
  server =>
535
751
  new Promise<void>((resolve) => {
536
752
  server.close(() => resolve());
537
753
  })
538
754
  );
755
+
756
+ // Stop the connection logger
539
757
  if (this.connectionLogger) {
540
758
  clearInterval(this.connectionLogger);
541
759
  this.connectionLogger = null;
542
760
  }
543
- await Promise.all(closePromises);
761
+
762
+ // Wait for servers to close
763
+ await Promise.all(closeServerPromises);
764
+ console.log("All servers closed. Cleaning up active connections...");
765
+
766
+ // Gracefully close active connections
767
+ const connectionIds = [...this.connectionRecords.keys()];
768
+ console.log(`Cleaning up ${connectionIds.length} active connections...`);
769
+
770
+ for (const id of connectionIds) {
771
+ const record = this.connectionRecords.get(id);
772
+ if (record && !record.connectionClosed && !record.cleanupInitiated) {
773
+ this.initiateCleanup(record, 'shutdown');
774
+ }
775
+ }
776
+
777
+ // Wait for graceful shutdown or timeout
778
+ const shutdownTimeout = this.settings.gracefulShutdownTimeout || 30000;
779
+ await new Promise<void>((resolve) => {
780
+ const checkInterval = setInterval(() => {
781
+ if (this.connectionRecords.size === 0) {
782
+ clearInterval(checkInterval);
783
+ resolve();
784
+ }
785
+ }, 1000);
786
+
787
+ // Force resolve after timeout
788
+ setTimeout(() => {
789
+ clearInterval(checkInterval);
790
+ if (this.connectionRecords.size > 0) {
791
+ console.log(`Forcing shutdown with ${this.connectionRecords.size} connections still active`);
792
+
793
+ // Force destroy any remaining connections
794
+ for (const record of this.connectionRecords.values()) {
795
+ if (!record.incoming.destroyed) {
796
+ record.incoming.destroy();
797
+ }
798
+ if (record.outgoing && !record.outgoing.destroyed) {
799
+ record.outgoing.destroy();
800
+ }
801
+ }
802
+ this.connectionRecords.clear();
803
+ }
804
+ resolve();
805
+ }, shutdownTimeout);
806
+ });
807
+
808
+ console.log("PortProxy shutdown complete.");
544
809
  }
545
810
  }