|
|
|
@ -164,6 +164,7 @@ interface ISNIExtractResult {
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
@ -732,15 +733,37 @@ 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();
|
|
|
|
@ -781,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
|
|
|
|
@ -820,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;
|
|
|
|
@ -831,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`
|
|
|
|
@ -846,6 +881,28 @@ export class PortProxy {
|
|
|
|
|
} else if (code === 'EHOSTUNREACH') {
|
|
|
|
|
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');
|
|
|
|
@ -958,11 +1015,28 @@ 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) {
|
|
|
|
@ -1105,15 +1179,46 @@ export class PortProxy {
|
|
|
|
|
// 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) {
|
|
|
|
|
const newDomainConfig = this.settings.domainConfigs.find((config) =>
|
|
|
|
|
// 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,
|
|
|
|
@ -1907,14 +2012,87 @@ 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) =>
|
|
|
|
|
config.domains.some((d) => plugins.minimatch(serverName, d))
|
|
|
|
|
)
|
|
|
|
|
: 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;
|
|
|
|
|
|
|
|
|
@ -2079,12 +2257,17 @@ 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`
|
|
|
|
@ -2098,10 +2281,25 @@ export class PortProxy {
|
|
|
|
|
// 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.
|
|
|
|
|