Compare commits

...

12 Commits

Author SHA1 Message Date
d8466a866c 3.31.2
Some checks failed
Default (tags) / security (push) Successful in 28s
Default (tags) / test (push) Failing after 1m3s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-11 03:56:09 +00:00
119b643690 fix(PortProxy): Improve SNI renegotiation handling by adding flexible domain configuration matching on rehandshake and session resumption events. 2025-03-11 03:56:09 +00:00
98f1e0df4c 3.31.1
Some checks failed
Default (tags) / security (push) Successful in 37s
Default (tags) / test (push) Failing after 1m3s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-11 03:48:10 +00:00
d6022c8f8a fix(PortProxy): Improve TLS handshake buffering and enhance debug logging for SNI forwarding in PortProxy 2025-03-11 03:48:10 +00:00
0ea0f02428 fix(PortProxy): Improve connection reliability for initial and resumed TLS sessions
Added enhanced connection handling to fix issues with both initial connections and TLS session resumption:

1. Improved debugging for connection setup with detailed logging
2. Added explicit timeout for backend connections to prevent hanging connections
3. Enhanced error recovery for connection failures with faster client notification
4. Added detailed session tracking to maintain domain context across TLS sessions
5. Fixed handling of TLS renegotiation with improved activity timestamp updates

This should address the issue where initial connections may fail but subsequent retries succeed,
as well as ensuring proper certificate selection for resumed TLS sessions.

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-11 03:33:03 +00:00
e452f55203 3.31.0
Some checks failed
Default (tags) / security (push) Successful in 35s
Default (tags) / test (push) Failing after 1m4s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-11 03:16:04 +00:00
55f25f1976 feat(PortProxy): Improve TLS handshake SNI extraction and add session resumption tracking in PortProxy 2025-03-11 03:16:04 +00:00
98b7f3ed7f 3.30.8
Some checks failed
Default (tags) / security (push) Failing after 11m56s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-03-11 02:50:01 +00:00
cb83caeafd fix(core): No changes in this commit. 2025-03-11 02:50:01 +00:00
7850a80452 fix(PortProxy): Fix TypeScript errors by using correct variable names
Fixed TypeScript errors caused by using 'connectionRecord' instead of 'record' in TLS renegotiation handlers.
The variable name mistake occurred when moving and restructuring the TLS handshake detection code.

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-11 02:47:57 +00:00
ef8f583a90 fix(PortProxy): Move TLS renegotiation detection before socket piping
Fundamentally restructured TLS renegotiation handling to ensure handshake packets are properly detected. The previous implementation attached event handlers after pipe() was established, which might have caused handshake packets to bypass detection. Key changes:

1. Moved renegotiation detection before pipe() to ensure all TLS handshake packets are detected
2. Added explicit lockedDomain setting for all SNI connections
3. Simplified the NetworkProxy TLS handshake detection
4. Removed redundant data handlers that could interfere with each other

These changes should make renegotiation detection more reliable regardless of how Node.js internal pipe() implementation handles data events.

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-11 02:45:51 +00:00
2bdd6f8c1f fix(PortProxy): Update activity timestamp during TLS renegotiation to prevent connection timeouts
Ensures that TLS renegotiation packets properly update the connection's activity timestamp even when no SNI is present or when there are errors processing the renegotiation. This prevents connections from being closed due to inactivity during legitimate TLS renegotiation.

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-11 02:40:08 +00:00
4 changed files with 618 additions and 140 deletions

View File

@ -1,5 +1,33 @@
# Changelog
## 2025-03-11 - 3.31.2 - fix(PortProxy)
Improve SNI renegotiation handling by adding flexible domain configuration matching on rehandshake and session resumption events.
- When a rehandshake is detected with a changed SNI, first check existing domain config rules and log if allowed.
- If the exact domain config is not found, additionally attempt flexible matching using parent domain and wildcard patterns.
- For resumed sessions, try an exact match first and then use fallback logic to select a similar domain config based on matching target IP.
- Enhanced logging added to help diagnose missing or mismatched domain configurations.
## 2025-03-11 - 3.31.1 - fix(PortProxy)
Improve TLS handshake buffering and enhance debug logging for SNI forwarding in PortProxy
- Explicitly copy the initial TLS handshake data to prevent mutation before buffering
- Log buffered TLS handshake data with SNI information for better diagnostics
- Add detailed error logs on TLS connection failures, including server and domain config status
- Output additional debug messages during ClientHello forwarding to verify proper TLS handshake processing
## 2025-03-11 - 3.31.0 - feat(PortProxy)
Improve TLS handshake SNI extraction and add session resumption tracking in PortProxy
- Added ITlsSessionInfo interface and a global tlsSessionCache to track TLS session IDs for session resumption
- Implemented a cleanup timer for the TLS session cache with startSessionCleanupTimer and stopSessionCleanupTimer
- Enhanced extractSNIInfo to return detailed SNI information including session IDs, ticket details, and resumption status
- Updated renegotiation handlers to use extractSNIInfo for proper SNI extraction during TLS rehandshake
## 2025-03-11 - 3.30.8 - fix(core)
No changes in this commit.
## 2025-03-11 - 3.30.7 - fix(PortProxy)
Improve TLS renegotiation SNI handling by first checking if the new SNI is allowed under the existing domain config. If not, attempt to find an alternative domain config and update the locked domain accordingly; otherwise, terminate the connection on SNI mismatch.

View File

@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartproxy",
"version": "3.30.7",
"version": "3.31.2",
"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

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '3.30.7',
version: '3.31.2',
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

@ -100,14 +100,83 @@ interface IConnectionRecord {
lastSleepDetection?: number; // Timestamp of the last sleep detection
}
/**
* Structure to track TLS session information for proper resumption handling
*/
interface ITlsSessionInfo {
domain: string; // The SNI domain associated with this session
sessionId?: Buffer; // The TLS session ID (if available)
ticketId?: string; // Session ticket identifier for newer TLS versions
ticketTimestamp: number; // When this session was recorded
}
// Global cache of TLS session IDs to SNI domains
// This ensures resumed sessions maintain their SNI binding
const tlsSessionCache = new Map<string, ITlsSessionInfo>();
// Reference to session cleanup timer so we can clear it
let tlsSessionCleanupTimer: NodeJS.Timeout | null = null;
// Start the cleanup timer for session cache
function startSessionCleanupTimer() {
// Avoid creating multiple timers
if (tlsSessionCleanupTimer) {
clearInterval(tlsSessionCleanupTimer);
}
// Create new cleanup timer
tlsSessionCleanupTimer = setInterval(() => {
const now = Date.now();
const expiryTime = 24 * 60 * 60 * 1000; // 24 hours
for (const [sessionId, info] of tlsSessionCache.entries()) {
if (now - info.ticketTimestamp > expiryTime) {
tlsSessionCache.delete(sessionId);
}
}
}, 60 * 60 * 1000); // Clean up once per hour
// Make sure the interval doesn't keep the process alive
if (tlsSessionCleanupTimer.unref) {
tlsSessionCleanupTimer.unref();
}
}
// Start the timer initially
startSessionCleanupTimer();
// Function to stop the cleanup timer (used during shutdown)
function stopSessionCleanupTimer() {
if (tlsSessionCleanupTimer) {
clearInterval(tlsSessionCleanupTimer);
tlsSessionCleanupTimer = null;
}
}
/**
* Return type for the extractSNIInfo function
*/
interface ISNIExtractResult {
serverName?: string; // The extracted SNI hostname
sessionId?: Buffer; // The TLS session ID if present
sessionIdKey?: string; // The hex string representation of session ID
sessionTicketId?: string; // Session ticket identifier for TLS 1.3+ resumption
hasSessionTicket?: boolean; // Whether a session ticket extension was found
isResumption: boolean; // Whether this appears to be a session resumption
resumedDomain?: string; // The domain associated with the session if resuming
partialExtract?: boolean; // Whether this was only a partial extraction (more data needed)
}
/**
* Extracts the SNI (Server Name Indication) from a TLS ClientHello packet.
* Enhanced for robustness and detailed logging.
* Also extracts and tracks TLS Session IDs for session resumption handling.
*
* @param buffer - Buffer containing the TLS ClientHello.
* @param enableLogging - Whether to enable detailed logging.
* @returns The server name if found, otherwise undefined.
* @returns An object containing SNI and session information, or undefined if parsing fails.
*/
function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | undefined {
function extractSNIInfo(buffer: Buffer, enableLogging: boolean = false): ISNIExtractResult | undefined {
try {
// Check if buffer is too small for TLS
if (buffer.length < 5) {
@ -153,9 +222,38 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un
offset += 2 + 32; // Skip client version and random
// Session ID
// Extract Session ID for session resumption tracking
const sessionIDLength = buffer.readUInt8(offset);
if (enableLogging) console.log(`Session ID Length: ${sessionIDLength}`);
// If there's a session ID, extract it
let sessionId: Buffer | undefined;
let sessionIdKey: string | undefined;
let isResumption = false;
let resumedDomain: string | undefined;
if (sessionIDLength > 0) {
sessionId = Buffer.from(buffer.slice(offset + 1, offset + 1 + sessionIDLength));
// Convert sessionId to a string key for our cache
sessionIdKey = sessionId.toString('hex');
if (enableLogging) {
console.log(`Session ID: ${sessionIdKey}`);
}
// Check if this is a session resumption attempt
if (tlsSessionCache.has(sessionIdKey)) {
const cachedInfo = tlsSessionCache.get(sessionIdKey)!;
resumedDomain = cachedInfo.domain;
isResumption = true;
if (enableLogging) {
console.log(`TLS Session Resumption detected for domain: ${resumedDomain}`);
}
}
}
offset += 1 + sessionIDLength; // Skip session ID
// Cipher suites
@ -194,6 +292,10 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un
return undefined;
}
// Variables to track session tickets
let hasSessionTicket = false;
let sessionTicketId: string | undefined;
// Parse extensions
while (offset + 4 <= extensionsEnd) {
const extensionType = buffer.readUInt16BE(offset);
@ -204,6 +306,33 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un
offset += 4;
// Check for Session Ticket extension (type 0x0023)
if (extensionType === 0x0023 && extensionLength > 0) {
hasSessionTicket = true;
// Extract a hash of the ticket for tracking
if (extensionLength > 16) { // Ensure we have enough bytes to create a meaningful ID
const ticketBytes = buffer.slice(offset, offset + Math.min(16, extensionLength));
sessionTicketId = ticketBytes.toString('hex');
if (enableLogging) {
console.log(`Session Ticket found, ID: ${sessionTicketId}`);
// Check if this is a known session ticket
if (tlsSessionCache.has(`ticket:${sessionTicketId}`)) {
const cachedInfo = tlsSessionCache.get(`ticket:${sessionTicketId}`);
console.log(`TLS Session Ticket Resumption detected for domain: ${cachedInfo?.domain}`);
// Set isResumption and resumedDomain if not already set
if (!isResumption && !resumedDomain) {
isResumption = true;
resumedDomain = cachedInfo?.domain;
}
}
}
}
}
if (extensionType === 0x0000) {
// SNI extension
if (offset + 2 > buffer.length) {
@ -245,7 +374,43 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un
const serverName = buffer.toString('utf8', offset, offset + nameLen);
if (enableLogging) console.log(`Extracted SNI: ${serverName}`);
return serverName;
// Store the session ID to domain mapping for future resumptions
if (sessionIdKey && sessionId && serverName) {
tlsSessionCache.set(sessionIdKey, {
domain: serverName,
sessionId: sessionId,
ticketTimestamp: Date.now()
});
if (enableLogging) {
console.log(`Stored session ${sessionIdKey} for domain ${serverName}`);
}
}
// Also store session ticket information if present
if (sessionTicketId && serverName) {
tlsSessionCache.set(`ticket:${sessionTicketId}`, {
domain: serverName,
ticketId: sessionTicketId,
ticketTimestamp: Date.now()
});
if (enableLogging) {
console.log(`Stored session ticket ${sessionTicketId} for domain ${serverName}`);
}
}
// Return the complete extraction result
return {
serverName,
sessionId,
sessionIdKey,
sessionTicketId,
isResumption,
resumedDomain,
hasSessionTicket
};
}
offset += nameLen;
@ -257,13 +422,46 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un
}
if (enableLogging) console.log('No SNI extension found');
return undefined;
// Even without SNI, we might be dealing with a session resumption
if (isResumption && resumedDomain) {
return {
serverName: resumedDomain, // Use the domain from previous session
sessionId,
sessionIdKey,
sessionTicketId,
hasSessionTicket,
isResumption: true,
resumedDomain
};
}
// Return a basic result with just the session info
return {
isResumption,
sessionId,
sessionIdKey,
sessionTicketId,
hasSessionTicket,
resumedDomain
};
} catch (err) {
console.log(`Error extracting SNI: ${err}`);
return undefined;
}
}
/**
* Legacy wrapper for extractSNIInfo to maintain backward compatibility
* @param buffer - Buffer containing the TLS ClientHello
* @param enableLogging - Whether to enable detailed logging
* @returns The server name if found, otherwise undefined
*/
function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | undefined {
const result = extractSNIInfo(buffer, enableLogging);
return result?.serverName;
}
// 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);
@ -502,27 +700,16 @@ export class PortProxy {
this.cleanupConnection(record, 'client_closed');
});
// Update activity on data transfer
// Special handler for TLS handshake detection with NetworkProxy
socket.on('data', (chunk: Buffer) => {
this.updateActivity(record);
// Check for potential TLS renegotiation or reconnection packets
// Check for TLS handshake packets (ContentType.handshake)
if (chunk.length > 0 && chunk[0] === 22) {
// ContentType.handshake
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Detected potential TLS handshake data while connected to NetworkProxy`
);
}
// NOTE: We don't need to explicitly forward the renegotiation packets
// because socket.pipe(proxySocket) is already handling that.
// The pipe ensures all data (including renegotiation) flows through properly.
// Just update the activity timestamp to prevent timeouts
record.lastActivity = Date.now();
console.log(`[${connectionId}] Detected potential TLS handshake with NetworkProxy, updating activity`);
this.updateActivity(record);
}
});
// Update activity on data transfer from the proxy socket
proxySocket.on('data', () => this.updateActivity(record));
if (this.settings.enableDetailedLogging) {
@ -546,16 +733,38 @@ export class PortProxy {
initialChunk?: Buffer,
overridePort?: number
): void {
// Enhanced logging for initial connection troubleshooting
if (serverName) {
console.log(`[${connectionId}] Setting up direct connection for domain: ${serverName}`);
} else {
console.log(`[${connectionId}] Setting up direct connection without SNI`);
}
// Log domain config details to help diagnose routing issues
if (domainConfig) {
console.log(`[${connectionId}] Using domain config: ${domainConfig.domains.join(', ')}`);
} else {
console.log(`[${connectionId}] No specific domain config found, using default settings`);
}
// Ensure we maximize connection chances by setting appropriate timeouts
socket.setTimeout(30000); // 30 second initial connect timeout
// Existing connection setup logic
const targetHost = domainConfig ? this.getTargetIP(domainConfig) : this.settings.targetIP!;
const connectionOptions: plugins.net.NetConnectOpts = {
host: targetHost,
port: overridePort !== undefined ? overridePort : this.settings.toPort,
// Add connection timeout to ensure we don't hang indefinitely
timeout: 15000 // 15 second connection timeout
};
if (this.settings.preserveSourceIP) {
connectionOptions.localAddress = record.remoteIP.replace('::ffff:', '');
}
console.log(`[${connectionId}] Connecting to backend: ${targetHost}:${connectionOptions.port}`);
// Pause the incoming socket to prevent buffer overflows
socket.pause();
@ -595,11 +804,22 @@ export class PortProxy {
// Add the temp handler to capture all incoming data during connection setup
socket.on('data', tempDataHandler);
// Add initial chunk to pending data if present
// Add initial chunk to pending data if present - this is critical for SNI forwarding
if (initialChunk) {
record.bytesReceived += initialChunk.length;
record.pendingData.push(Buffer.from(initialChunk));
record.pendingDataSize = initialChunk.length;
// Make explicit copy of the buffer to ensure it doesn't get modified
const initialDataCopy = Buffer.from(initialChunk);
record.bytesReceived += initialDataCopy.length;
record.pendingData.push(initialDataCopy);
record.pendingDataSize = initialDataCopy.length;
// Log TLS handshake for debug purposes
if (isTlsHandshake(initialChunk)) {
record.isTLS = true;
console.log(`[${connectionId}] Buffered TLS handshake data: ${initialDataCopy.length} bytes, SNI: ${serverName || 'none'}`);
}
} else if (record.isTLS) {
// This shouldn't happen, but log a warning if we have a TLS connection with no initial data
console.log(`[${connectionId}] WARNING: TLS connection without initial handshake data`);
}
// Create the target socket but don't set up piping immediately
@ -634,7 +854,7 @@ export class PortProxy {
}
}
// Setup specific error handler for connection phase
// Setup specific error handler for connection phase with enhanced retries
targetSocket.once('error', (err) => {
// This handler runs only once during the initial connection phase
const code = (err as any).code;
@ -645,6 +865,7 @@ export class PortProxy {
// Resume the incoming socket to prevent it from hanging
socket.resume();
// Add detailed logging for connection problems
if (code === 'ECONNREFUSED') {
console.log(
`[${connectionId}] Target ${targetHost}:${connectionOptions.port} refused connection`
@ -661,6 +882,28 @@ export class PortProxy {
console.log(`[${connectionId}] Host ${targetHost} is unreachable`);
}
// Log additional diagnostics
console.log(`[${connectionId}] Connection details - SNI: ${serverName || 'none'}, HasChunk: ${!!initialChunk}, ChunkSize: ${initialChunk ? initialChunk.length : 0}`);
// For TLS connections, provide even more detailed diagnostics
if (record.isTLS) {
console.log(`[${connectionId}] TLS connection failure details - TLS detected: ${record.isTLS}, Server: ${targetHost}:${connectionOptions.port}, Domain config: ${domainConfig ? 'Present' : 'Missing'}`);
}
// For connection refusal or timeouts, try a more aggressive error response
// This helps browsers quickly realize there's an issue rather than waiting
if (code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'EHOSTUNREACH') {
try {
// Send a RST packet rather than a graceful close
// This signals to browsers to try a new connection immediately
socket.destroy(new Error(`Backend connection failed: ${code}`));
console.log(`[${connectionId}] Forced connection termination to trigger immediate browser retry`);
return; // Skip normal cleanup
} catch (destroyErr) {
console.log(`[${connectionId}] Error during forced connection termination: ${destroyErr}`);
}
}
// Clear any existing error handler after connection phase
targetSocket.removeAllListeners('error');
@ -772,12 +1015,105 @@ export class PortProxy {
// Flush all pending data to target
if (record.pendingData.length > 0) {
const combinedData = Buffer.concat(record.pendingData);
// Add critical debugging for SNI forwarding issues
if (record.isTLS && this.settings.enableTlsDebugLogging) {
console.log(`[${connectionId}] Forwarding TLS handshake data: ${combinedData.length} bytes, SNI: ${serverName || 'none'}`);
// Additional check to verify we're forwarding the ClientHello properly
if (combinedData[0] === 22) { // TLS handshake
console.log(`[${connectionId}] Initial data is a TLS handshake record`);
}
}
// Write the combined data to the target
targetSocket.write(combinedData, (err) => {
if (err) {
console.log(`[${connectionId}] Error writing pending data to target: ${err.message}`);
return this.initiateCleanupOnce(record, 'write_error');
}
if (record.isTLS) {
// Log successful forwarding of initial TLS data
console.log(`[${connectionId}] Successfully forwarded initial TLS data to backend`);
}
// Set up the renegotiation listener *before* piping if this is a TLS connection with SNI
if (serverName && record.isTLS) {
// This listener handles TLS renegotiation detection
socket.on('data', (renegChunk) => {
if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
// Always update activity timestamp for any handshake packet
this.updateActivity(record);
try {
// Extract all TLS information including session resumption data
const sniInfo = extractSNIInfo(renegChunk, this.settings.enableTlsDebugLogging);
let newSNI = sniInfo?.serverName;
// Handle session resumption - if we recognize the session ID, we know what domain it belongs to
if (sniInfo?.isResumption && sniInfo.resumedDomain) {
console.log(`[${connectionId}] Rehandshake with session resumption for domain: ${sniInfo.resumedDomain}`);
newSNI = sniInfo.resumedDomain;
}
// IMPORTANT: If we can't extract an SNI from renegotiation, we MUST allow it through
if (newSNI === undefined) {
console.log(`[${connectionId}] Rehandshake detected without SNI, allowing it through.`);
return;
}
// Check if the SNI has changed
if (newSNI !== serverName) {
console.log(`[${connectionId}] Rehandshake with different SNI: ${newSNI} vs original ${serverName}`);
// Allow if the new SNI matches existing domain config or find a new matching config
let allowed = false;
if (record.domainConfig) {
allowed = record.domainConfig.domains.some(d => plugins.minimatch(newSNI, d));
}
if (!allowed) {
const newDomainConfig = this.settings.domainConfigs.find((config) =>
config.domains.some((d) => plugins.minimatch(newSNI, d))
);
if (newDomainConfig) {
const effectiveAllowedIPs = [
...newDomainConfig.allowedIPs,
...(this.settings.defaultAllowedIPs || []),
];
const effectiveBlockedIPs = [
...(newDomainConfig.blockedIPs || []),
...(this.settings.defaultBlockedIPs || []),
];
allowed = isGlobIPAllowed(record.remoteIP, effectiveAllowedIPs, effectiveBlockedIPs);
if (allowed) {
record.domainConfig = newDomainConfig;
}
}
}
if (allowed) {
console.log(`[${connectionId}] Updated domain for connection from ${record.remoteIP} to: ${newSNI}`);
record.lockedDomain = newSNI;
} else {
console.log(`[${connectionId}] Rehandshake SNI ${newSNI} not allowed. Terminating connection.`);
this.initiateCleanupOnce(record, 'sni_mismatch');
}
} else {
console.log(`[${connectionId}] Rehandshake with same SNI: ${newSNI}`);
}
} catch (err) {
console.log(`[${connectionId}] Error processing renegotiation: ${err}. Allowing to continue.`);
}
}
});
}
// Now set up piping for future data and resume the socket
socket.pipe(targetSocket);
targetSocket.pipe(socket);
@ -811,7 +1147,114 @@ export class PortProxy {
}
});
} else {
// No pending data, so just set up piping
// Set up the renegotiation listener *before* piping if this is a TLS connection with SNI
if (serverName && record.isTLS) {
// This listener handles TLS renegotiation detection
socket.on('data', (renegChunk) => {
if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
// Always update activity timestamp for any handshake packet
this.updateActivity(record);
try {
// Extract all TLS information including session resumption data
const sniInfo = extractSNIInfo(renegChunk, this.settings.enableTlsDebugLogging);
let newSNI = sniInfo?.serverName;
// Handle session resumption - if we recognize the session ID, we know what domain it belongs to
if (sniInfo?.isResumption && sniInfo.resumedDomain) {
console.log(`[${connectionId}] Rehandshake with session resumption for domain: ${sniInfo.resumedDomain}`);
newSNI = sniInfo.resumedDomain;
}
// IMPORTANT: If we can't extract an SNI from renegotiation, we MUST allow it through
if (newSNI === undefined) {
console.log(`[${connectionId}] Rehandshake detected without SNI, allowing it through.`);
return;
}
// Check if the SNI has changed
if (newSNI !== serverName) {
console.log(`[${connectionId}] Rehandshake with different SNI: ${newSNI} vs original ${serverName}`);
// Allow if the new SNI matches existing domain config or find a new matching config
let allowed = false;
// First check if the new SNI is allowed under the existing domain config
// This is the preferred approach as it maintains the existing connection context
if (record.domainConfig) {
allowed = record.domainConfig.domains.some(d => plugins.minimatch(newSNI, d));
if (allowed) {
console.log(`[${connectionId}] Rehandshake SNI ${newSNI} allowed by existing domain config`);
}
}
// If not allowed by existing config, try to find an alternative domain config
if (!allowed) {
// First try exact match
let newDomainConfig = this.settings.domainConfigs.find((config) =>
config.domains.some((d) => plugins.minimatch(newSNI, d))
);
// If no exact match, try flexible matching with domain parts (for wildcard domains)
if (!newDomainConfig) {
console.log(`[${connectionId}] No exact domain config match for rehandshake SNI: ${newSNI}, trying flexible matching`);
const domainParts = newSNI.split('.');
// Try matching with parent domains or wildcard patterns
if (domainParts.length > 2) {
const parentDomain = domainParts.slice(1).join('.');
const wildcardDomain = '*.' + parentDomain;
console.log(`[${connectionId}] Trying alternative patterns: ${parentDomain} or ${wildcardDomain}`);
newDomainConfig = this.settings.domainConfigs.find((config) =>
config.domains.some((d) =>
d === parentDomain ||
d === wildcardDomain ||
plugins.minimatch(parentDomain, d)
)
);
}
}
if (newDomainConfig) {
const effectiveAllowedIPs = [
...newDomainConfig.allowedIPs,
...(this.settings.defaultAllowedIPs || []),
];
const effectiveBlockedIPs = [
...(newDomainConfig.blockedIPs || []),
...(this.settings.defaultBlockedIPs || []),
];
allowed = isGlobIPAllowed(record.remoteIP, effectiveAllowedIPs, effectiveBlockedIPs);
if (allowed) {
record.domainConfig = newDomainConfig;
}
}
}
if (allowed) {
console.log(`[${connectionId}] Updated domain for connection from ${record.remoteIP} to: ${newSNI}`);
record.lockedDomain = newSNI;
} else {
console.log(`[${connectionId}] Rehandshake SNI ${newSNI} not allowed. Terminating connection.`);
this.initiateCleanupOnce(record, 'sni_mismatch');
}
} else {
console.log(`[${connectionId}] Rehandshake with same SNI: ${newSNI}`);
}
} catch (err) {
console.log(`[${connectionId}] Error processing renegotiation: ${err}. Allowing to continue.`);
}
}
});
}
// Now set up piping
socket.pipe(targetSocket);
targetSocket.pipe(socket);
socket.resume(); // Resume the socket after piping is established
@ -848,113 +1291,8 @@ export class PortProxy {
record.pendingData = [];
record.pendingDataSize = 0;
// Add the renegotiation listener for SNI validation
if (serverName) {
// This listener will check for TLS renegotiation attempts
// Note: We don't need to explicitly forward the renegotiation packets
// since socket.pipe(targetSocket) is already set up earlier and handles that
socket.on('data', (renegChunk: Buffer) => {
if (renegChunk.length > 0 && renegChunk.readUInt8(0) === 22) {
try {
// Try to extract SNI from potential renegotiation
const newSNI = extractSNI(renegChunk, this.settings.enableTlsDebugLogging);
// IMPORTANT: If we can't extract an SNI from renegotiation, we MUST allow it through
// Otherwise valid renegotiations that don't explicitly repeat the SNI will break
if (newSNI === undefined) {
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Rehandshake detected without SNI, allowing it through.`
);
}
// Let it pass through - this is critical for Chrome's TLS handling
return;
}
// Check if the SNI has changed
if (newSNI && newSNI !== record.lockedDomain) {
// Always check whether the new SNI would be allowed by the EXISTING domain config first
// This ensures we're using the same ruleset that allowed the initial connection
let allowed = false;
// First check if the exact original domain config would allow this new SNI
if (record.domainConfig) {
// Check if the new SNI matches any domain pattern in the original domain config
allowed = record.domainConfig.domains.some(d => plugins.minimatch(newSNI, d));
if (allowed && this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Rehandshake with new SNI: ${newSNI} matched existing domain config ` +
`patterns ${record.domainConfig.domains.join(', ')}. Allowing connection reuse.`
);
}
}
// If not allowed by the existing domain config, try to find another domain config
if (!allowed) {
const newDomainConfig = this.settings.domainConfigs.find((config) =>
config.domains.some((d) => plugins.minimatch(newSNI, d))
);
// If we found a matching domain config, check IP rules
if (newDomainConfig) {
const effectiveAllowedIPs = [
...newDomainConfig.allowedIPs,
...(this.settings.defaultAllowedIPs || []),
];
const effectiveBlockedIPs = [
...(newDomainConfig.blockedIPs || []),
...(this.settings.defaultBlockedIPs || []),
];
// Check if the IP is allowed for the new domain
allowed = isGlobIPAllowed(record.remoteIP, effectiveAllowedIPs, effectiveBlockedIPs);
if (allowed && this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Rehandshake with new SNI: ${newSNI} (previously ${record.lockedDomain}). ` +
`New domain is allowed by different domain config rules, permitting connection reuse.`
);
}
// Update the domain config reference to the new one
if (allowed) {
record.domainConfig = newDomainConfig;
}
}
}
if (allowed) {
// Update the locked domain to the new domain
record.lockedDomain = newSNI;
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Updated locked domain for connection from ${record.remoteIP} to: ${newSNI}`
);
}
} else {
// If we get here, either no matching domain config was found or the IP is not allowed
console.log(
`[${connectionId}] Rehandshake detected with different SNI: ${newSNI} vs locked ${record.lockedDomain}. ` +
`New domain not allowed by any rules. Terminating connection.`
);
this.initiateCleanupOnce(record, 'sni_mismatch');
}
} else if (newSNI && this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Rehandshake detected with same SNI: ${newSNI}. Allowing.`
);
}
} catch (err) {
// Always allow the renegotiation to continue if we encounter an error
// This ensures Chrome can complete its TLS renegotiation
console.log(
`[${connectionId}] Error processing potential renegotiation: ${err}. Allowing connection to continue.`
);
}
}
});
}
// Renegotiation detection is now handled before piping is established
// This ensures the data listener receives all packets properly
// Set connection timeout with simpler logic
if (record.cleanupTimer) {
@ -1674,7 +2012,7 @@ export class PortProxy {
}
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
const domainConfig = forcedDomain
let domainConfig = forcedDomain
? forcedDomain
: serverName
? this.settings.domainConfigs.find((config) =>
@ -1682,9 +2020,88 @@ export class PortProxy {
)
: undefined;
// Enhanced logging to diagnose domain config selection issues
if (serverName && !domainConfig) {
console.log(`[${connectionId}] WARNING: No domain config found for SNI: ${serverName}`);
console.log(`[${connectionId}] Available domains:`,
this.settings.domainConfigs.map(config => config.domains.join(',')).join(' | '));
} else if (serverName && domainConfig) {
console.log(`[${connectionId}] Found domain config for SNI: ${serverName} -> ${domainConfig.domains.join(',')}`);
}
// For session resumption, ensure we use the domain config matching the resumed domain
// The resumed domain will be in serverName if this is a session resumption
if (serverName && connectionRecord.lockedDomain === serverName && serverName !== '') {
// Override domain config lookup for session resumption - crucial for certificate selection
// First try an exact match
let resumedDomainConfig = this.settings.domainConfigs.find((config) =>
config.domains.some((d) => plugins.minimatch(serverName, d))
);
// If no exact match found, try a more flexible approach using domain parts
if (!resumedDomainConfig) {
console.log(`[${connectionId}] No exact domain config match for resumed domain: ${serverName}, trying flexible matching`);
// Extract domain parts (e.g., for "sub.example.com" try matching with "*.example.com")
const domainParts = serverName.split('.');
// Try matching with parent domains or wildcard patterns
if (domainParts.length > 2) {
const parentDomain = domainParts.slice(1).join('.');
const wildcardDomain = '*.' + parentDomain;
console.log(`[${connectionId}] Trying alternative patterns: ${parentDomain} or ${wildcardDomain}`);
resumedDomainConfig = this.settings.domainConfigs.find((config) =>
config.domains.some((d) =>
d === parentDomain ||
d === wildcardDomain ||
plugins.minimatch(parentDomain, d)
)
);
}
}
if (resumedDomainConfig) {
domainConfig = resumedDomainConfig;
console.log(`[${connectionId}] Found domain config for resumed session: ${serverName} -> ${resumedDomainConfig.domains.join(',')}`);
} else {
// As a fallback, use the first domain config with the same target IP if possible
if (domainConfig && domainConfig.targetIPs && domainConfig.targetIPs.length > 0) {
const targetIP = domainConfig.targetIPs[0];
const similarConfig = this.settings.domainConfigs.find((config) =>
config.targetIPs && config.targetIPs.includes(targetIP)
);
if (similarConfig && similarConfig !== domainConfig) {
console.log(`[${connectionId}] Using similar domain config with matching target IP for resumed domain: ${serverName}`);
domainConfig = similarConfig;
} else {
console.log(`[${connectionId}] WARNING: Cannot find domain config for resumed domain: ${serverName}`);
// Log available domains to help diagnose the issue
console.log(`[${connectionId}] Available domains:`,
this.settings.domainConfigs.map(config => config.domains.join(',')).join(' | '));
}
} else {
console.log(`[${connectionId}] WARNING: Cannot find domain config for resumed domain: ${serverName}`);
// Log available domains to help diagnose the issue
console.log(`[${connectionId}] Available domains:`,
this.settings.domainConfigs.map(config => config.domains.join(',')).join(' | '));
}
}
}
// Save domain config in connection record
connectionRecord.domainConfig = domainConfig;
// Always set the lockedDomain, even for non-SNI connections
if (serverName) {
connectionRecord.lockedDomain = serverName;
console.log(`[${connectionId}] Locked connection to domain: ${serverName}`);
}
// IP validation is skipped if allowedIPs is empty
if (domainConfig) {
const effectiveAllowedIPs: string[] = [
@ -1840,19 +2257,49 @@ export class PortProxy {
initialDataReceived = true;
// Try to extract SNI
// Try to extract SNI - with enhanced logging for troubleshooting
let serverName = '';
// Record the chunk size for diagnostic purposes
console.log(`[${connectionId}] Received initial data: ${chunk.length} bytes`);
if (isTlsHandshake(chunk)) {
connectionRecord.isTLS = true;
console.log(`[${connectionId}] Detected TLS handshake`);
if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] Extracting SNI from TLS handshake, ${chunk.length} bytes`
);
}
serverName = extractSNI(chunk, this.settings.enableTlsDebugLogging) || '';
// Extract all TLS information including session resumption
const sniInfo = extractSNIInfo(chunk, this.settings.enableTlsDebugLogging);
if (sniInfo?.isResumption && sniInfo.resumedDomain) {
// This is a session resumption with a known domain
serverName = sniInfo.resumedDomain;
console.log(`[${connectionId}] TLS Session resumption detected for domain: ${serverName}`);
// When resuming a session, explicitly set the domain in the record to ensure proper routing
// This is CRITICAL for ensuring we select the correct backend/certificate
connectionRecord.lockedDomain = serverName;
// Force detailed logging for resumed sessions to help with troubleshooting
console.log(`[${connectionId}] Resuming TLS session for domain ${serverName} - will use original certificate`);
} else {
// Normal SNI extraction
serverName = sniInfo?.serverName || '';
if (serverName) {
console.log(`[${connectionId}] Extracted SNI domain: ${serverName}`);
} else {
console.log(`[${connectionId}] No SNI found in TLS handshake`);
}
}
} else {
console.log(`[${connectionId}] Non-TLS connection detected`);
}
// Lock the connection to the negotiated SNI.
@ -2169,6 +2616,9 @@ export class PortProxy {
console.log('PortProxy shutting down...');
this.isShuttingDown = true;
// Stop the session cleanup timer
stopSessionCleanupTimer();
// Stop accepting new connections
const closeServerPromises: Promise<void>[] = this.netServers.map(
(server) =>