@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.
package/ts/classes.portproxy.ts
CHANGED
|
@@ -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; //
|
|
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:
|
|
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
|
-
*
|
|
163
|
-
*
|
|
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
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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.
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
if (
|
|
181
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
|
218
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
550
|
+
initiateCleanupOnce('timeout');
|
|
351
551
|
});
|
|
552
|
+
|
|
352
553
|
socket.on('end', handleClose('incoming'));
|
|
353
554
|
targetSocket.on('end', handleClose('outgoing'));
|
|
354
555
|
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
516
|
-
if
|
|
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(`
|
|
519
|
-
this.
|
|
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
|
-
|
|
533
|
-
|
|
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
|
-
|
|
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
|
}
|