@push.rocks/smartproxy 3.20.2 → 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,85 +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
|
+
);
|
|
488
|
+
|
|
489
|
+
// Setup data flow after confirmed connection
|
|
490
|
+
setupDataFlow(targetSocket, initialChunk);
|
|
491
|
+
});
|
|
318
492
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
//
|
|
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
|
+
});
|
|
564
|
+
|
|
565
|
+
// Initialize a cleanup timer for max connection lifetime
|
|
356
566
|
if (this.settings.maxConnectionLifetime) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
});
|
|
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);
|
|
394
571
|
}
|
|
395
572
|
};
|
|
396
573
|
|
|
@@ -401,6 +578,7 @@ export class PortProxy {
|
|
|
401
578
|
if (this.settings.defaultAllowedIPs && !isAllowed(remoteIP, this.settings.defaultAllowedIPs)) {
|
|
402
579
|
console.log(`Connection from ${remoteIP} rejected: IP ${remoteIP} not allowed in global default allowed list.`);
|
|
403
580
|
socket.end();
|
|
581
|
+
initiateCleanupOnce('rejected');
|
|
404
582
|
return;
|
|
405
583
|
}
|
|
406
584
|
console.log(`Port-based connection from ${remoteIP} on port ${localPort} forwarded to global target IP ${this.settings.targetIP}.`);
|
|
@@ -429,6 +607,7 @@ export class PortProxy {
|
|
|
429
607
|
if (!isGlobIPAllowed(remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
|
|
430
608
|
console.log(`Connection from ${remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join(', ')} on port ${localPort}.`);
|
|
431
609
|
socket.end();
|
|
610
|
+
initiateCleanupOnce('rejected');
|
|
432
611
|
return;
|
|
433
612
|
}
|
|
434
613
|
console.log(`Port-based connection from ${remoteIP} on port ${localPort} matched domain ${forcedDomain.domains.join(', ')}.`);
|
|
@@ -441,32 +620,36 @@ export class PortProxy {
|
|
|
441
620
|
|
|
442
621
|
// --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabled) ---
|
|
443
622
|
if (this.settings.sniEnabled) {
|
|
444
|
-
|
|
445
|
-
console.log(`Initial data timeout for ${remoteIP}`);
|
|
446
|
-
socket.end();
|
|
447
|
-
cleanupOnce();
|
|
448
|
-
});
|
|
623
|
+
initialDataReceived = false;
|
|
449
624
|
|
|
450
625
|
socket.once('data', (chunk: Buffer) => {
|
|
451
|
-
socket.setTimeout(0);
|
|
452
626
|
initialDataReceived = true;
|
|
453
627
|
const serverName = extractSNI(chunk) || '';
|
|
454
628
|
// Lock the connection to the negotiated SNI.
|
|
455
629
|
connectionRecord.lockedDomain = serverName;
|
|
456
630
|
console.log(`Received connection from ${remoteIP} with SNI: ${serverName}`);
|
|
631
|
+
|
|
457
632
|
// Delay adding the renegotiation listener until the next tick,
|
|
458
633
|
// so the initial ClientHello is not reprocessed.
|
|
459
634
|
setImmediate(() => {
|
|
460
635
|
socket.on('data', (renegChunk: Buffer) => {
|
|
461
636
|
if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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.`);
|
|
466
648
|
}
|
|
467
649
|
}
|
|
468
650
|
});
|
|
469
651
|
});
|
|
652
|
+
|
|
470
653
|
setupConnection(serverName, chunk);
|
|
471
654
|
});
|
|
472
655
|
} else {
|
|
@@ -507,23 +690,48 @@ export class PortProxy {
|
|
|
507
690
|
this.netServers.push(server);
|
|
508
691
|
}
|
|
509
692
|
|
|
510
|
-
// Log active connection count,
|
|
693
|
+
// Log active connection count, run parity checks, and check for connection issues every 10 seconds.
|
|
511
694
|
this.connectionLogger = setInterval(() => {
|
|
695
|
+
if (this.isShuttingDown) return;
|
|
696
|
+
|
|
512
697
|
const now = Date.now();
|
|
513
698
|
let maxIncoming = 0;
|
|
514
699
|
let maxOutgoing = 0;
|
|
515
|
-
|
|
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
|
+
|
|
516
708
|
maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
|
|
517
709
|
if (record.outgoingStartTime) {
|
|
518
710
|
maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
|
|
519
711
|
}
|
|
520
|
-
|
|
521
|
-
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) {
|
|
522
729
|
const remoteIP = record.incoming.remoteAddress || 'unknown';
|
|
523
|
-
console.log(`
|
|
524
|
-
this.
|
|
730
|
+
console.log(`Inactivity check triggered: No activity on connection from ${remoteIP} for ${plugins.prettyMs(inactivityTime)}.`);
|
|
731
|
+
this.initiateCleanup(record, 'inactivity');
|
|
525
732
|
}
|
|
526
733
|
}
|
|
734
|
+
|
|
527
735
|
console.log(
|
|
528
736
|
`(Interval Log) Active connections: ${this.connectionRecords.size}. ` +
|
|
529
737
|
`Longest running incoming: ${plugins.prettyMs(maxIncoming)}, outgoing: ${plugins.prettyMs(maxOutgoing)}. ` +
|
|
@@ -534,17 +742,69 @@ export class PortProxy {
|
|
|
534
742
|
}
|
|
535
743
|
|
|
536
744
|
public async stop() {
|
|
537
|
-
|
|
538
|
-
|
|
745
|
+
console.log("PortProxy shutting down...");
|
|
746
|
+
this.isShuttingDown = true;
|
|
747
|
+
|
|
748
|
+
// Stop accepting new connections
|
|
749
|
+
const closeServerPromises: Promise<void>[] = this.netServers.map(
|
|
539
750
|
server =>
|
|
540
751
|
new Promise<void>((resolve) => {
|
|
541
752
|
server.close(() => resolve());
|
|
542
753
|
})
|
|
543
754
|
);
|
|
755
|
+
|
|
756
|
+
// Stop the connection logger
|
|
544
757
|
if (this.connectionLogger) {
|
|
545
758
|
clearInterval(this.connectionLogger);
|
|
546
759
|
this.connectionLogger = null;
|
|
547
760
|
}
|
|
548
|
-
|
|
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.");
|
|
549
809
|
}
|
|
550
810
|
}
|