Compare commits

..

4 Commits

Author SHA1 Message Date
0674ca7163 3.34.0
Some checks failed
Default (tags) / security (push) Failing after 12m28s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-03-11 11:34:29 +00:00
e31c84493f feat(core): Improve wildcard domain matching and enhance NetworkProxy integration in PortProxy. Added support for TLD wildcards and complex wildcard patterns in the router, and refactored TLS renegotiation handling for stricter SNI enforcement. 2025-03-11 11:34:29 +00:00
d2ad659d37 3.33.0
Some checks failed
Default (tags) / security (push) Successful in 34s
Default (tags) / test (push) Failing after 14m16s
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-03-11 09:57:06 +00:00
df7a12041e feat(portproxy): Add browser-friendly mode and SNI renegotiation configuration options to PortProxy 2025-03-11 09:57:06 +00:00
6 changed files with 729 additions and 427 deletions

View File

@ -1,5 +1,22 @@
# Changelog
## 2025-03-11 - 3.34.0 - feat(core)
Improve wildcard domain matching and enhance NetworkProxy integration in PortProxy. Added support for TLD wildcards and complex wildcard patterns in the router, and refactored TLS renegotiation handling for stricter SNI enforcement.
- Added support for TLD wildcard matching (e.g., 'example.*') to improve domain routing.
- Implemented complex wildcard pattern matching (e.g., '*.lossless*') in the router.
- Enhanced NetworkProxy integration by initializing a single NetworkProxy instance and forwarding TLS connections accordingly.
- Refactored TLS renegotiation handling to terminate connections on SNI mismatch for stricter enforcement.
- Updated tests to cover the new wildcard matching scenarios.
## 2025-03-11 - 3.33.0 - feat(portproxy)
Add browser-friendly mode and SNI renegotiation configuration options to PortProxy
- Introduce new properties: browserFriendlyMode (default true) to optimize handling for browser connections.
- Add allowRenegotiationWithDifferentSNI (default false) to enable or disable SNI changes during renegotiation.
- Include relatedDomainPatterns to define patterns for related domains that can share connections.
- Update TypeScript interfaces and internal renegotiation logic to support these options.
## 2025-03-11 - 3.32.2 - fix(PortProxy)
Simplify TLS handshake SNI extraction and update timeout settings in PortProxy for improved maintainability and reliability.

View File

@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartproxy",
"version": "3.32.2",
"version": "3.34.0",
"private": false,
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.",
"main": "dist_ts/index.js",

View File

@ -197,6 +197,52 @@ tap.test('should match wildcard subdomains', async () => {
expect(result).toEqual(wildcardConfig);
});
// Test TLD wildcards (example.*)
tap.test('should match TLD wildcards', async () => {
const tldWildcardConfig = createProxyConfig('example.*');
router.setNewProxyConfigs([tldWildcardConfig]);
// Test that example.com matches example.*
const req1 = createMockRequest('example.com');
const result1 = router.routeReq(req1);
expect(result1).toBeTruthy();
expect(result1).toEqual(tldWildcardConfig);
// Test that example.org matches example.*
const req2 = createMockRequest('example.org');
const result2 = router.routeReq(req2);
expect(result2).toBeTruthy();
expect(result2).toEqual(tldWildcardConfig);
// Test that subdomain.example.com doesn't match example.*
const req3 = createMockRequest('subdomain.example.com');
const result3 = router.routeReq(req3);
expect(result3).toBeUndefined();
});
// Test complex pattern matching (*.lossless*)
tap.test('should match complex wildcard patterns', async () => {
const complexWildcardConfig = createProxyConfig('*.lossless*');
router.setNewProxyConfigs([complexWildcardConfig]);
// Test that sub.lossless.com matches *.lossless*
const req1 = createMockRequest('sub.lossless.com');
const result1 = router.routeReq(req1);
expect(result1).toBeTruthy();
expect(result1).toEqual(complexWildcardConfig);
// Test that api.lossless.org matches *.lossless*
const req2 = createMockRequest('api.lossless.org');
const result2 = router.routeReq(req2);
expect(result2).toBeTruthy();
expect(result2).toEqual(complexWildcardConfig);
// Test that losslessapi.com matches *.lossless*
const req3 = createMockRequest('losslessapi.com');
const result3 = router.routeReq(req3);
expect(result3).toBeUndefined(); // Should not match as it doesn't have a subdomain
});
// Test default configuration fallback
tap.test('should fall back to default configuration', async () => {
const defaultConfig = createProxyConfig('*');

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '3.32.2',
version: '3.34.0',
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.'
}

View File

@ -10,10 +10,6 @@ export interface IDomainConfig {
portRanges?: Array<{ from: number; to: number }>; // Optional port ranges
// Allow domain-specific timeout override
connectionTimeout?: number; // Connection timeout override (ms)
// New properties for NetworkProxy integration
useNetworkProxy?: boolean; // When true, forwards TLS connections to NetworkProxy
networkProxyIndex?: number; // Optional index to specify which NetworkProxy to use (defaults to 0)
}
/** Port proxy settings including global allowed port ranges */
@ -61,7 +57,8 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions {
extendedKeepAliveLifetime?: number; // Extended lifetime for keep-alive connections (ms)
// New property for NetworkProxy integration
networkProxies?: NetworkProxy[]; // Array of NetworkProxy instances to use for TLS termination
useNetworkProxy?: number[]; // Array of ports to forward to NetworkProxy
networkProxyPort?: number; // Port where NetworkProxy is listening (default: 8443)
}
/**
@ -97,9 +94,15 @@ interface IConnectionRecord {
incomingTerminationReason?: string | null; // Reason for incoming termination
outgoingTerminationReason?: string | null; // Reason for outgoing termination
// New field for NetworkProxy tracking
// NetworkProxy tracking
usingNetworkProxy?: boolean; // Whether this connection is using a NetworkProxy
networkProxyIndex?: number; // Which NetworkProxy instance is being used
// Renegotiation handler
renegotiationHandler?: (chunk: Buffer) => void; // Handler for renegotiation detection
// Browser connection tracking
isBrowserConnection?: boolean; // Whether this connection appears to be from a browser
domainSwitches?: number; // Number of times the domain has been switched on this connection
}
/**
@ -266,6 +269,29 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un
}
}
/**
* Checks if a TLS record is a proper ClientHello message (more accurate than just checking record type)
* @param buffer - Buffer containing the TLS record
* @returns true if the buffer contains a proper ClientHello message
*/
function isClientHello(buffer: Buffer): boolean {
try {
if (buffer.length < 9) return false; // Too small for a proper ClientHello
// Check record type (has to be handshake - 22)
if (buffer.readUInt8(0) !== 22) return false;
// After the TLS record header (5 bytes), check the handshake type (1 for ClientHello)
if (buffer.readUInt8(5) !== 1) return false;
// Basic checks passed, this appears to be a ClientHello
return true;
} catch (err) {
console.log(`Error checking for ClientHello: ${err}`);
return false;
}
}
// Helper: Check if a port falls within any of the given port ranges
const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => {
return ranges.some((range) => port >= range.from && port <= range.to);
@ -349,8 +375,8 @@ export class PortProxy {
private connectionsByIP: Map<string, Set<string>> = new Map();
private connectionRateByIP: Map<string, number[]> = new Map();
// New property to store NetworkProxy instances
private networkProxies: NetworkProxy[] = [];
// NetworkProxy instance for TLS termination
private networkProxy: NetworkProxy | null = null;
constructor(settingsArg: IPortProxySettings) {
// Set reasonable defaults for all settings
@ -370,29 +396,49 @@ export class PortProxy {
// Socket optimization settings
noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true,
keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true,
keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 10000, // 10 seconds (reduced for responsiveness)
maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024, // 10MB to handle large TLS handshakes
keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 10000, // 10 seconds
maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024, // 10MB
// Feature flags
disableInactivityCheck: settingsArg.disableInactivityCheck || false,
enableKeepAliveProbes: settingsArg.enableKeepAliveProbes !== undefined
? settingsArg.enableKeepAliveProbes : true, // Enable by default
? settingsArg.enableKeepAliveProbes : true,
enableDetailedLogging: settingsArg.enableDetailedLogging || false,
enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false,
enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false, // Disable randomization by default
enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false,
// Rate limiting defaults
maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100, // 100 connections per IP
connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300, // 300 per minute
maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100,
connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300,
// Enhanced keep-alive settings
keepAliveTreatment: settingsArg.keepAliveTreatment || 'extended', // Extended by default
keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6, // 6x normal inactivity timeout
keepAliveTreatment: settingsArg.keepAliveTreatment || 'extended',
keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6,
extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000, // 7 days
// NetworkProxy settings
networkProxyPort: settingsArg.networkProxyPort || 8443, // Default NetworkProxy port
};
// Store NetworkProxy instances if provided
this.networkProxies = settingsArg.networkProxies || [];
// Initialize NetworkProxy if enabled
if (this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0) {
this.initializeNetworkProxy();
}
}
/**
* Initialize NetworkProxy instance
*/
private initializeNetworkProxy(): void {
if (!this.networkProxy) {
this.networkProxy = new NetworkProxy({
port: this.settings.networkProxyPort!,
portProxyIntegration: true,
logLevel: this.settings.enableDetailedLogging ? 'debug' : 'info'
});
console.log(`Initialized NetworkProxy on port ${this.settings.networkProxyPort}`);
}
}
/**
@ -400,51 +446,49 @@ export class PortProxy {
* @param connectionId - Unique connection identifier
* @param socket - The incoming client socket
* @param record - The connection record
* @param domainConfig - The domain configuration
* @param initialData - Initial data chunk (TLS ClientHello)
* @param serverName - SNI hostname (if available)
*/
private forwardToNetworkProxy(
connectionId: string,
socket: plugins.net.Socket,
record: IConnectionRecord,
domainConfig: IDomainConfig,
initialData: Buffer,
serverName?: string
initialData: Buffer
): void {
// Determine which NetworkProxy to use
const proxyIndex = domainConfig.networkProxyIndex !== undefined
? domainConfig.networkProxyIndex
: 0;
// Validate the NetworkProxy index
if (proxyIndex < 0 || proxyIndex >= this.networkProxies.length) {
console.log(`[${connectionId}] Invalid NetworkProxy index: ${proxyIndex}. Using fallback direct connection.`);
// Ensure NetworkProxy is initialized
if (!this.networkProxy) {
console.log(
`[${connectionId}] NetworkProxy not initialized. Using fallback direct connection.`
);
// Fall back to direct connection
return this.setupDirectConnection(connectionId, socket, record, domainConfig, serverName, initialData);
return this.setupDirectConnection(
connectionId,
socket,
record,
undefined,
undefined,
initialData
);
}
const networkProxy = this.networkProxies[proxyIndex];
const proxyPort = networkProxy.getListeningPort();
const proxyPort = this.networkProxy.getListeningPort();
const proxyHost = 'localhost'; // Assuming NetworkProxy runs locally
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Forwarding TLS connection to NetworkProxy[${proxyIndex}] at ${proxyHost}:${proxyPort}`
`[${connectionId}] Forwarding TLS connection to NetworkProxy at ${proxyHost}:${proxyPort}`
);
}
// Create a connection to the NetworkProxy
const proxySocket = plugins.net.connect({
host: proxyHost,
port: proxyPort
port: proxyPort,
});
// Store the outgoing socket in the record
record.outgoing = proxySocket;
record.outgoingStartTime = Date.now();
record.usingNetworkProxy = true;
record.networkProxyIndex = proxyIndex;
// Set up error handlers
proxySocket.on('error', (err) => {
@ -475,7 +519,9 @@ export class PortProxy {
socket.on('close', () => {
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Client connection closed after forwarding to NetworkProxy`);
console.log(
`[${connectionId}] Client connection closed after forwarding to NetworkProxy`
);
}
this.cleanupConnection(record, 'client_closed');
});
@ -486,7 +532,7 @@ export class PortProxy {
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] TLS connection successfully forwarded to NetworkProxy[${proxyIndex}]`
`[${connectionId}] TLS connection successfully forwarded to NetworkProxy`
);
}
});
@ -585,7 +631,9 @@ export class PortProxy {
} catch (err) {
// Ignore errors - these are optional enhancements
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Enhanced TCP keep-alive not supported for outgoing socket: ${err}`);
console.log(
`[${connectionId}] Enhanced TCP keep-alive not supported for outgoing socket: ${err}`
);
}
}
}
@ -642,7 +690,9 @@ export class PortProxy {
// For keep-alive connections, just log a warning instead of closing
if (record.hasKeepAlive) {
console.log(
`[${connectionId}] Timeout event on incoming keep-alive connection from ${record.remoteIP} after ${plugins.prettyMs(
`[${connectionId}] Timeout event on incoming keep-alive connection from ${
record.remoteIP
} after ${plugins.prettyMs(
this.settings.socketTimeout || 3600000
)}. Connection preserved.`
);
@ -652,9 +702,9 @@ export class PortProxy {
// For non-keep-alive connections, proceed with normal cleanup
console.log(
`[${connectionId}] Timeout on incoming side from ${record.remoteIP} after ${plugins.prettyMs(
this.settings.socketTimeout || 3600000
)}`
`[${connectionId}] Timeout on incoming side from ${
record.remoteIP
} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`
);
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'timeout';
@ -667,7 +717,9 @@ export class PortProxy {
// For keep-alive connections, just log a warning instead of closing
if (record.hasKeepAlive) {
console.log(
`[${connectionId}] Timeout event on outgoing keep-alive connection from ${record.remoteIP} after ${plugins.prettyMs(
`[${connectionId}] Timeout event on outgoing keep-alive connection from ${
record.remoteIP
} after ${plugins.prettyMs(
this.settings.socketTimeout || 3600000
)}. Connection preserved.`
);
@ -677,9 +729,9 @@ export class PortProxy {
// For non-keep-alive connections, proceed with normal cleanup
console.log(
`[${connectionId}] Timeout on outgoing side from ${record.remoteIP} after ${plugins.prettyMs(
this.settings.socketTimeout || 3600000
)}`
`[${connectionId}] Timeout on outgoing side from ${
record.remoteIP
} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`
);
if (record.outgoingTerminationReason === null) {
record.outgoingTerminationReason = 'timeout';
@ -695,7 +747,9 @@ export class PortProxy {
targetSocket.setTimeout(0);
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Disabled socket timeouts for immortal keep-alive connection`);
console.log(
`[${connectionId}] Disabled socket timeouts for immortal keep-alive connection`
);
}
} else {
// Set normal timeouts for other connections
@ -725,9 +779,7 @@ export class PortProxy {
const combinedData = Buffer.concat(record.pendingData);
targetSocket.write(combinedData, (err) => {
if (err) {
console.log(
`[${connectionId}] Error writing pending data to target: ${err.message}`
);
console.log(`[${connectionId}] Error writing pending data to target: ${err.message}`);
return this.initiateCleanupOnce(record, 'write_error');
}
@ -746,7 +798,9 @@ export class PortProxy {
? ` (Port-based for domain: ${domainConfig.domains.join(', ')})`
: ''
}` +
` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${record.hasKeepAlive ? 'Yes' : 'No'}`
` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${
record.hasKeepAlive ? 'Yes' : 'No'
}`
);
} else {
console.log(
@ -777,7 +831,9 @@ export class PortProxy {
? ` (Port-based for domain: ${domainConfig.domains.join(', ')})`
: ''
}` +
` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${record.hasKeepAlive ? 'Yes' : 'No'}`
` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${
record.hasKeepAlive ? 'Yes' : 'No'
}`
);
} else {
console.log(
@ -797,30 +853,45 @@ export class PortProxy {
record.pendingData = [];
record.pendingDataSize = 0;
// Add the renegotiation listener for SNI validation
// Add the renegotiation handler for SNI validation with strict domain enforcement
if (serverName) {
socket.on('data', (renegChunk: Buffer) => {
if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
// Define a handler for checking renegotiation with improved detection
const renegotiationHandler = (renegChunk: Buffer) => {
// Only process if this looks like a TLS ClientHello
if (isClientHello(renegChunk)) {
try {
// Try to extract SNI from potential renegotiation
// Extract SNI from ClientHello
const newSNI = extractSNI(renegChunk, this.settings.enableTlsDebugLogging);
if (newSNI && newSNI !== record.lockedDomain) {
// Skip if no SNI was found
if (!newSNI) return;
// Handle SNI change during renegotiation - always terminate for domain switches
if (newSNI !== record.lockedDomain) {
// Log and terminate the connection for any SNI change
console.log(
`[${connectionId}] Rehandshake detected with different SNI: ${newSNI} vs locked ${record.lockedDomain}. Terminating connection.`
`[${connectionId}] Renegotiation with different SNI: ${record.lockedDomain} -> ${newSNI}. ` +
`Terminating connection - SNI domain switching is not allowed.`
);
this.initiateCleanupOnce(record, 'sni_mismatch');
} else if (newSNI && this.settings.enableDetailedLogging) {
} else if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Rehandshake detected with same SNI: ${newSNI}. Allowing.`
`[${connectionId}] Renegotiation detected with same SNI: ${newSNI}. Allowing.`
);
}
} catch (err) {
console.log(
`[${connectionId}] Error processing potential renegotiation: ${err}. Allowing connection to continue.`
`[${connectionId}] Error processing ClientHello: ${err}. Allowing connection to continue.`
);
}
}
});
};
// Store the handler in the connection record so we can remove it during cleanup
record.renegotiationHandler = renegotiationHandler;
// Add the listener
socket.on('data', renegotiationHandler);
}
// Set connection timeout with simpler logic
@ -831,7 +902,9 @@ export class PortProxy {
// For immortal keep-alive connections, skip setting a timeout completely
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Keep-alive connection with immortal treatment - no max lifetime`);
console.log(
`[${connectionId}] Keep-alive connection with immortal treatment - no max lifetime`
);
}
// No cleanup timer for immortal connections
}
@ -842,9 +915,9 @@ export class PortProxy {
record.cleanupTimer = setTimeout(() => {
console.log(
`[${connectionId}] Keep-alive connection from ${record.remoteIP} exceeded extended lifetime (${plugins.prettyMs(
extendedTimeout
)}), forcing cleanup.`
`[${connectionId}] Keep-alive connection from ${
record.remoteIP
} exceeded extended lifetime (${plugins.prettyMs(extendedTimeout)}), forcing cleanup.`
);
this.initiateCleanupOnce(record, 'extended_lifetime');
}, safeTimeout);
@ -855,20 +928,25 @@ export class PortProxy {
}
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Keep-alive connection with extended lifetime of ${plugins.prettyMs(extendedTimeout)}`);
console.log(
`[${connectionId}] Keep-alive connection with extended lifetime of ${plugins.prettyMs(
extendedTimeout
)}`
);
}
}
// For standard connections, use normal timeout
else {
// Use domain-specific timeout if available, otherwise use default
const connectionTimeout = record.domainConfig?.connectionTimeout || this.settings.maxConnectionLifetime!;
const connectionTimeout =
record.domainConfig?.connectionTimeout || this.settings.maxConnectionLifetime!;
const safeTimeout = ensureSafeTimeout(connectionTimeout);
record.cleanupTimer = setTimeout(() => {
console.log(
`[${connectionId}] Connection from ${record.remoteIP} exceeded max lifetime (${plugins.prettyMs(
connectionTimeout
)}), forcing cleanup.`
`[${connectionId}] Connection from ${
record.remoteIP
} exceeded max lifetime (${plugins.prettyMs(connectionTimeout)}), forcing cleanup.`
);
this.initiateCleanupOnce(record, 'connection_timeout');
}, safeTimeout);
@ -973,6 +1051,16 @@ export class PortProxy {
const bytesReceived = record.bytesReceived;
const bytesSent = record.bytesSent;
// Remove the renegotiation handler if present
if (record.renegotiationHandler && record.incoming) {
try {
record.incoming.removeListener('data', record.renegotiationHandler);
record.renegotiationHandler = undefined;
} catch (err) {
console.log(`[${record.id}] Error removing renegotiation handler: ${err}`);
}
}
try {
if (!record.incoming.destroyed) {
// Try graceful shutdown first, then force destroy after a short timeout
@ -1047,8 +1135,11 @@ export class PortProxy {
` Duration: ${plugins.prettyMs(
duration
)}, Bytes IN: ${bytesReceived}, OUT: ${bytesSent}, ` +
`TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${record.hasKeepAlive ? 'Yes' : 'No'}` +
`${record.usingNetworkProxy ? `, NetworkProxy: ${record.networkProxyIndex}` : ''}`
`TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${
record.hasKeepAlive ? 'Yes' : 'No'
}` +
`${record.usingNetworkProxy ? ', Using NetworkProxy' : ''}` +
`${record.domainSwitches ? `, Domain switches: ${record.domainSwitches}` : ''}`
);
} else {
console.log(
@ -1091,7 +1182,10 @@ export class PortProxy {
console.log(`[${record.id}] Connection cleanup initiated for ${record.remoteIP} (${reason})`);
}
if (record.incomingTerminationReason === null || record.incomingTerminationReason === undefined) {
if (
record.incomingTerminationReason === null ||
record.incomingTerminationReason === undefined
) {
record.incomingTerminationReason = reason;
this.incrementTerminationStat('incoming', reason);
}
@ -1184,6 +1278,12 @@ export class PortProxy {
return;
}
// Start NetworkProxy if configured
if (this.networkProxy) {
await this.networkProxy.start();
console.log(`NetworkProxy started on port ${this.settings.networkProxyPort}`);
}
// Define a unified connection handler for all listening ports.
const connectionHandler = (socket: plugins.net.Socket) => {
if (this.isShuttingDown) {
@ -1244,8 +1344,12 @@ export class PortProxy {
incomingTerminationReason: null,
outgoingTerminationReason: null,
// Initialize NetworkProxy tracking fields
usingNetworkProxy: false
// Initialize NetworkProxy tracking
usingNetworkProxy: false,
// Initialize browser connection tracking
isBrowserConnection: false,
domainSwitches: 0,
};
// Apply keep-alive settings if enabled
@ -1266,7 +1370,9 @@ export class PortProxy {
} catch (err) {
// Ignore errors - these are optional enhancements
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Enhanced TCP keep-alive settings not supported: ${err}`);
console.log(
`[${connectionId}] Enhanced TCP keep-alive settings not supported: ${err}`
);
}
}
}
@ -1288,8 +1394,63 @@ export class PortProxy {
);
}
// Check if this connection should be forwarded directly to NetworkProxy based on port
const shouldUseNetworkProxy = this.settings.useNetworkProxy &&
this.settings.useNetworkProxy.includes(localPort);
if (shouldUseNetworkProxy) {
// For NetworkProxy ports, we want to capture the TLS handshake and forward directly
let initialDataReceived = false;
// Set an initial timeout for handshake data
let initialTimeout: NodeJS.Timeout | null = setTimeout(() => {
if (!initialDataReceived) {
console.log(
`[${connectionId}] Initial data timeout (${this.settings.initialDataTimeout}ms) for connection from ${remoteIP} on port ${localPort}`
);
if (connectionRecord.incomingTerminationReason === null) {
connectionRecord.incomingTerminationReason = 'initial_timeout';
this.incrementTerminationStat('incoming', 'initial_timeout');
}
socket.end();
this.cleanupConnection(connectionRecord, 'initial_timeout');
}
}, this.settings.initialDataTimeout!);
// Make sure timeout doesn't keep the process alive
if (initialTimeout.unref) {
initialTimeout.unref();
}
socket.on('error', this.handleError('incoming', connectionRecord));
// First data handler to capture initial TLS handshake for NetworkProxy
socket.once('data', (chunk: Buffer) => {
// Clear the initial timeout since we've received data
if (initialTimeout) {
clearTimeout(initialTimeout);
initialTimeout = null;
}
initialDataReceived = true;
connectionRecord.hasReceivedInitialData = true;
// Check if this looks like a TLS handshake
if (isTlsHandshake(chunk)) {
connectionRecord.isTLS = true;
// Forward directly to NetworkProxy without SNI processing
this.forwardToNetworkProxy(connectionId, socket, connectionRecord, chunk);
} else {
// If not TLS, use normal direct connection
console.log(`[${connectionId}] Non-TLS connection on NetworkProxy port ${localPort}`);
this.setupDirectConnection(connectionId, socket, connectionRecord, undefined, undefined, chunk);
}
});
} else {
// For non-NetworkProxy ports, proceed with normal processing
// Define helpers for rejecting connections
const rejectIncomingConnection = (reason: string, logMessage: string) => {
console.log(`[${connectionId}] ${logMessage}`);
@ -1301,6 +1462,8 @@ export class PortProxy {
this.cleanupConnection(connectionRecord, reason);
};
let initialDataReceived = false;
// Set an initial timeout for SNI data if needed
let initialTimeout: NodeJS.Timeout | null = null;
if (this.settings.sniEnabled) {
@ -1349,7 +1512,7 @@ export class PortProxy {
});
/**
* Sets up the connection to the target host or NetworkProxy.
* Sets up the connection to the target host.
* @param serverName - The SNI hostname (unused when forcedDomain is provided).
* @param initialChunk - Optional initial data chunk.
* @param forcedDomain - If provided, overrides SNI/domain lookup (used for port-based routing).
@ -1418,23 +1581,6 @@ export class PortProxy {
)}`
);
}
// Check if we should forward this to a NetworkProxy
if (
isTlsHandshakeDetected &&
domainConfig.useNetworkProxy === true &&
initialChunk &&
this.networkProxies.length > 0
) {
return this.forwardToNetworkProxy(
connectionId,
socket,
connectionRecord,
domainConfig,
initialChunk,
serverName
);
}
} else if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) {
if (
!isGlobIPAllowed(
@ -1450,7 +1596,12 @@ export class PortProxy {
}
}
// If we didn't forward to NetworkProxy, proceed with direct connection
// Save the initial SNI
if (serverName) {
connectionRecord.lockedDomain = serverName;
}
// Set up the direct connection
return this.setupDirectConnection(
connectionId,
socket,
@ -1595,6 +1746,7 @@ export class PortProxy {
setupConnection('');
}
}
};
// --- SETUP LISTENERS ---
@ -1619,10 +1771,11 @@ export class PortProxy {
console.log(`Server Error on port ${port}: ${err.message}`);
});
server.listen(port, () => {
const isNetworkProxyPort = this.settings.useNetworkProxy?.includes(port);
console.log(
`PortProxy -> OK: Now listening on port ${port}${
this.settings.sniEnabled ? ' (SNI passthrough enabled)' : ''
}${this.networkProxies.length > 0 ? ' (NetworkProxy integration enabled)' : ''}`
this.settings.sniEnabled && !isNetworkProxyPort ? ' (SNI passthrough enabled)' : ''
}${isNetworkProxyPort ? ' (NetworkProxy forwarding enabled)' : ''}`
);
});
this.netServers.push(server);
@ -1642,6 +1795,7 @@ export class PortProxy {
let pendingTlsHandshakes = 0;
let keepAliveConnections = 0;
let networkProxyConnections = 0;
let domainSwitchedConnections = 0;
// Create a copy of the keys to avoid modification during iteration
const connectionIds = [...this.connectionRecords.keys()];
@ -1670,11 +1824,14 @@ export class PortProxy {
networkProxyConnections++;
}
if (record.domainSwitches && record.domainSwitches > 0) {
domainSwitchedConnections++;
}
maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
if (record.outgoingStartTime) {
maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
}
// Parity check: if outgoing socket closed and incoming remains active
if (
record.outgoingClosedTime &&
@ -1706,9 +1863,10 @@ export class PortProxy {
}
// Skip inactivity check if disabled or for immortal keep-alive connections
if (!this.settings.disableInactivityCheck &&
!(record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal')) {
if (
!this.settings.disableInactivityCheck &&
!(record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal')
) {
const inactivityTime = now - record.lastActivity;
// Use extended timeout for extended-treatment keep-alive connections
@ -1722,7 +1880,9 @@ export class PortProxy {
// For keep-alive connections, issue a warning first
if (record.hasKeepAlive && !record.inactivityWarningIssued) {
console.log(
`[${id}] Warning: Keep-alive connection from ${record.remoteIP} inactive for ${plugins.prettyMs(inactivityTime)}. ` +
`[${id}] Warning: Keep-alive connection from ${
record.remoteIP
} inactive for ${plugins.prettyMs(inactivityTime)}. ` +
`Will close in 10 minutes if no activity.`
);
@ -1754,7 +1914,9 @@ export class PortProxy {
} else if (inactivityTime <= effectiveTimeout && record.inactivityWarningIssued) {
// If activity detected after warning, clear the warning
if (this.settings.enableDetailedLogging) {
console.log(`[${id}] Connection activity detected after inactivity warning, resetting warning`);
console.log(
`[${id}] Connection activity detected after inactivity warning, resetting warning`
);
}
record.inactivityWarningIssued = false;
}
@ -1765,7 +1927,8 @@ export class PortProxy {
console.log(
`Active connections: ${this.connectionRecords.size}. ` +
`Types: TLS=${tlsConnections} (Completed=${completedTlsHandshakes}, Pending=${pendingTlsHandshakes}), ` +
`Non-TLS=${nonTlsConnections}, KeepAlive=${keepAliveConnections}, NetworkProxy=${networkProxyConnections}. ` +
`Non-TLS=${nonTlsConnections}, KeepAlive=${keepAliveConnections}, NetworkProxy=${networkProxyConnections}, ` +
`DomainSwitched=${domainSwitchedConnections}. ` +
`Longest running: IN=${plugins.prettyMs(maxIncoming)}, OUT=${plugins.prettyMs(
maxOutgoing
)}. ` +
@ -1782,21 +1945,6 @@ export class PortProxy {
}
}
/**
* Add or replace NetworkProxy instances
*/
public setNetworkProxies(networkProxies: NetworkProxy[]): void {
this.networkProxies = networkProxies;
console.log(`Updated NetworkProxy instances: ${this.networkProxies.length} proxies configured`);
}
/**
* Get a list of configured NetworkProxy instances
*/
public getNetworkProxies(): NetworkProxy[] {
return this.networkProxies;
}
/**
* Gracefully shut down the proxy
*/
@ -1888,6 +2036,16 @@ export class PortProxy {
}
}
// Stop NetworkProxy if it was started
if (this.networkProxy) {
try {
await this.networkProxy.stop();
console.log('NetworkProxy stopped successfully');
} catch (err) {
console.log(`Error stopping NetworkProxy: ${err}`);
}
}
// Clear all tracking maps
this.connectionRecords.clear();
this.domainTargetIndices.clear();

View File

@ -19,6 +19,21 @@ export interface IRouterResult {
pathRemainder?: string;
}
/**
* Router for HTTP reverse proxy requests
*
* Supports the following domain matching patterns:
* - Exact matches: "example.com"
* - Wildcard subdomains: "*.example.com" (matches any subdomain of example.com)
* - TLD wildcards: "example.*" (matches example.com, example.org, etc.)
* - Complex wildcards: "*.lossless*" (matches any subdomain of any lossless domain)
* - Default fallback: "*" (matches any unmatched domain)
*
* Also supports path pattern matching for each domain:
* - Exact path: "/api/users"
* - Wildcard paths: "/api/*"
* - Path parameters: "/users/:id/profile"
*/
export class ProxyRouter {
// Store original configs for reference
private reverseProxyConfigs: tsclass.network.IReverseProxyConfig[] = [];
@ -98,9 +113,11 @@ export class ProxyRouter {
return exactConfig;
}
// Try wildcard subdomain
// Try various wildcard patterns
if (hostWithoutPort.includes('.')) {
const domainParts = hostWithoutPort.split('.');
// Try wildcard subdomain (*.example.com)
if (domainParts.length > 2) {
const wildcardDomain = `*.${domainParts.slice(1).join('.')}`;
const wildcardConfig = this.findConfigForHost(wildcardDomain, urlPath);
@ -108,6 +125,23 @@ export class ProxyRouter {
return wildcardConfig;
}
}
// Try TLD wildcard (example.*)
const baseDomain = domainParts.slice(0, -1).join('.');
const tldWildcardDomain = `${baseDomain}.*`;
const tldWildcardConfig = this.findConfigForHost(tldWildcardDomain, urlPath);
if (tldWildcardConfig) {
return tldWildcardConfig;
}
// Try complex wildcard patterns
const wildcardPatterns = this.findWildcardMatches(hostWithoutPort);
for (const pattern of wildcardPatterns) {
const wildcardConfig = this.findConfigForHost(pattern, urlPath);
if (wildcardConfig) {
return wildcardConfig;
}
}
}
// Fall back to default config if available
@ -120,6 +154,53 @@ export class ProxyRouter {
return undefined;
}
/**
* Find potential wildcard patterns that could match a given hostname
* Handles complex patterns like "*.lossless*" or other partial matches
* @param hostname The hostname to find wildcard matches for
* @returns Array of potential wildcard patterns that could match
*/
private findWildcardMatches(hostname: string): string[] {
const patterns: string[] = [];
const hostnameParts = hostname.split('.');
// Find all configured hostnames that contain wildcards
const wildcardConfigs = this.reverseProxyConfigs.filter(
config => config.hostName.includes('*')
);
// Extract unique wildcard patterns
const wildcardPatterns = [...new Set(
wildcardConfigs.map(config => config.hostName.toLowerCase())
)];
// For each wildcard pattern, check if it could match the hostname
// using simplified regex pattern matching
for (const pattern of wildcardPatterns) {
// Skip the default wildcard '*'
if (pattern === '*') continue;
// Skip already checked patterns (*.domain.com and domain.*)
if (pattern.startsWith('*.') && pattern.indexOf('*', 2) === -1) continue;
if (pattern.endsWith('.*') && pattern.indexOf('*') === pattern.length - 1) continue;
// Convert wildcard pattern to regex
const regexPattern = pattern
.replace(/\./g, '\\.') // Escape dots
.replace(/\*/g, '.*'); // Convert * to .* for regex
// Create regex object with case insensitive flag
const regex = new RegExp(`^${regexPattern}$`, 'i');
// If hostname matches this complex pattern, add it to the list
if (regex.test(hostname)) {
patterns.push(pattern);
}
}
return patterns;
}
/**
* Find a config for a specific host and path
*/