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