Compare commits
9 Commits
Author | SHA1 | Date | |
---|---|---|---|
e452f55203 | |||
55f25f1976 | |||
98b7f3ed7f | |||
cb83caeafd | |||
7850a80452 | |||
ef8f583a90 | |||
2bdd6f8c1f | |||
99d28eafd1 | |||
788b444fcc |
20
changelog.md
20
changelog.md
@ -1,5 +1,25 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
- Added a preliminary check against the original domain config to allow re-handshakes if the new SNI matches allowed patterns.
|
||||||
|
- If the original config does not allow, search for an alternative domain config and validate IP rules.
|
||||||
|
- Update the locked domain when allowed, ensuring connection reuse with valid certificate context.
|
||||||
|
- Terminate the connection if no suitable domain config is found or IP restrictions are violated.
|
||||||
|
|
||||||
## 2025-03-11 - 3.30.6 - fix(PortProxy)
|
## 2025-03-11 - 3.30.6 - fix(PortProxy)
|
||||||
Improve TLS renegotiation handling in PortProxy by validating the new SNI against allowed domain configurations. If the new SNI is permitted based on existing IP rules, update the locked domain to allow connection reuse; otherwise, terminate the connection to prevent misrouting.
|
Improve TLS renegotiation handling in PortProxy by validating the new SNI against allowed domain configurations. If the new SNI is permitted based on existing IP rules, update the locked domain to allow connection reuse; otherwise, terminate the connection to prevent misrouting.
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartproxy",
|
"name": "@push.rocks/smartproxy",
|
||||||
"version": "3.30.6",
|
"version": "3.31.0",
|
||||||
"private": false,
|
"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.",
|
"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",
|
"main": "dist_ts/index.js",
|
||||||
|
@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
name: '@push.rocks/smartproxy',
|
||||||
version: '3.30.6',
|
version: '3.31.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.'
|
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.'
|
||||||
}
|
}
|
||||||
|
@ -100,14 +100,82 @@ interface IConnectionRecord {
|
|||||||
lastSleepDetection?: number; // Timestamp of the last sleep detection
|
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
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the SNI (Server Name Indication) from a TLS ClientHello packet.
|
* Extracts the SNI (Server Name Indication) from a TLS ClientHello packet.
|
||||||
* Enhanced for robustness and detailed logging.
|
* 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 buffer - Buffer containing the TLS ClientHello.
|
||||||
* @param enableLogging - Whether to enable detailed logging.
|
* @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 {
|
try {
|
||||||
// Check if buffer is too small for TLS
|
// Check if buffer is too small for TLS
|
||||||
if (buffer.length < 5) {
|
if (buffer.length < 5) {
|
||||||
@ -153,9 +221,38 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un
|
|||||||
|
|
||||||
offset += 2 + 32; // Skip client version and random
|
offset += 2 + 32; // Skip client version and random
|
||||||
|
|
||||||
// Session ID
|
// Extract Session ID for session resumption tracking
|
||||||
const sessionIDLength = buffer.readUInt8(offset);
|
const sessionIDLength = buffer.readUInt8(offset);
|
||||||
if (enableLogging) console.log(`Session ID Length: ${sessionIDLength}`);
|
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
|
offset += 1 + sessionIDLength; // Skip session ID
|
||||||
|
|
||||||
// Cipher suites
|
// Cipher suites
|
||||||
@ -194,6 +291,10 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Variables to track session tickets
|
||||||
|
let hasSessionTicket = false;
|
||||||
|
let sessionTicketId: string | undefined;
|
||||||
|
|
||||||
// Parse extensions
|
// Parse extensions
|
||||||
while (offset + 4 <= extensionsEnd) {
|
while (offset + 4 <= extensionsEnd) {
|
||||||
const extensionType = buffer.readUInt16BE(offset);
|
const extensionType = buffer.readUInt16BE(offset);
|
||||||
@ -204,6 +305,33 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un
|
|||||||
|
|
||||||
offset += 4;
|
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) {
|
if (extensionType === 0x0000) {
|
||||||
// SNI extension
|
// SNI extension
|
||||||
if (offset + 2 > buffer.length) {
|
if (offset + 2 > buffer.length) {
|
||||||
@ -245,7 +373,43 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un
|
|||||||
|
|
||||||
const serverName = buffer.toString('utf8', offset, offset + nameLen);
|
const serverName = buffer.toString('utf8', offset, offset + nameLen);
|
||||||
if (enableLogging) console.log(`Extracted SNI: ${serverName}`);
|
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;
|
offset += nameLen;
|
||||||
@ -257,13 +421,46 @@ function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | un
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (enableLogging) console.log('No SNI extension found');
|
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) {
|
} catch (err) {
|
||||||
console.log(`Error extracting SNI: ${err}`);
|
console.log(`Error extracting SNI: ${err}`);
|
||||||
return undefined;
|
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
|
// Helper: Check if a port falls within any of the given port ranges
|
||||||
const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => {
|
const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => {
|
||||||
return ranges.some((range) => port >= range.from && port <= range.to);
|
return ranges.some((range) => port >= range.from && port <= range.to);
|
||||||
@ -502,27 +699,16 @@ export class PortProxy {
|
|||||||
this.cleanupConnection(record, 'client_closed');
|
this.cleanupConnection(record, 'client_closed');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update activity on data transfer
|
// Special handler for TLS handshake detection with NetworkProxy
|
||||||
socket.on('data', (chunk: Buffer) => {
|
socket.on('data', (chunk: Buffer) => {
|
||||||
this.updateActivity(record);
|
// Check for TLS handshake packets (ContentType.handshake)
|
||||||
|
|
||||||
// Check for potential TLS renegotiation or reconnection packets
|
|
||||||
if (chunk.length > 0 && chunk[0] === 22) {
|
if (chunk.length > 0 && chunk[0] === 22) {
|
||||||
// ContentType.handshake
|
console.log(`[${connectionId}] Detected potential TLS handshake with NetworkProxy, updating activity`);
|
||||||
if (this.settings.enableDetailedLogging) {
|
this.updateActivity(record);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update activity on data transfer from the proxy socket
|
||||||
proxySocket.on('data', () => this.updateActivity(record));
|
proxySocket.on('data', () => this.updateActivity(record));
|
||||||
|
|
||||||
if (this.settings.enableDetailedLogging) {
|
if (this.settings.enableDetailedLogging) {
|
||||||
@ -778,6 +964,82 @@ export class PortProxy {
|
|||||||
return this.initiateCleanupOnce(record, 'write_error');
|
return this.initiateCleanupOnce(record, 'write_error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Now set up piping for future data and resume the socket
|
||||||
socket.pipe(targetSocket);
|
socket.pipe(targetSocket);
|
||||||
targetSocket.pipe(socket);
|
targetSocket.pipe(socket);
|
||||||
@ -811,7 +1073,83 @@ export class PortProxy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} 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;
|
||||||
|
|
||||||
|
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
|
||||||
socket.pipe(targetSocket);
|
socket.pipe(targetSocket);
|
||||||
targetSocket.pipe(socket);
|
targetSocket.pipe(socket);
|
||||||
socket.resume(); // Resume the socket after piping is established
|
socket.resume(); // Resume the socket after piping is established
|
||||||
@ -848,85 +1186,8 @@ export class PortProxy {
|
|||||||
record.pendingData = [];
|
record.pendingData = [];
|
||||||
record.pendingDataSize = 0;
|
record.pendingDataSize = 0;
|
||||||
|
|
||||||
// Add the renegotiation listener for SNI validation
|
// Renegotiation detection is now handled before piping is established
|
||||||
if (serverName) {
|
// This ensures the data listener receives all packets properly
|
||||||
// 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) {
|
|
||||||
// Instead of immediately terminating, check if the new SNI would be allowed
|
|
||||||
// by the same ruleset that allowed the initial connection
|
|
||||||
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
|
|
||||||
if (isGlobIPAllowed(record.remoteIP, effectiveAllowedIPs, effectiveBlockedIPs)) {
|
|
||||||
// Allow the domain switch - Chrome is reusing the connection for a different domain
|
|
||||||
if (this.settings.enableDetailedLogging) {
|
|
||||||
console.log(
|
|
||||||
`[${connectionId}] Rehandshake with new SNI: ${newSNI} (previously ${record.lockedDomain}). ` +
|
|
||||||
`New domain is allowed by rules, permitting connection reuse.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the locked domain to the new domain
|
|
||||||
record.lockedDomain = newSNI;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 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.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set connection timeout with simpler logic
|
// Set connection timeout with simpler logic
|
||||||
if (record.cleanupTimer) {
|
if (record.cleanupTimer) {
|
||||||
@ -1657,6 +1918,12 @@ export class PortProxy {
|
|||||||
// Save domain config in connection record
|
// Save domain config in connection record
|
||||||
connectionRecord.domainConfig = domainConfig;
|
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
|
// IP validation is skipped if allowedIPs is empty
|
||||||
if (domainConfig) {
|
if (domainConfig) {
|
||||||
const effectiveAllowedIPs: string[] = [
|
const effectiveAllowedIPs: string[] = [
|
||||||
@ -1824,7 +2091,17 @@ export class PortProxy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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}`);
|
||||||
|
} else {
|
||||||
|
// Normal SNI extraction
|
||||||
|
serverName = sniInfo?.serverName || '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lock the connection to the negotiated SNI.
|
// Lock the connection to the negotiated SNI.
|
||||||
@ -2141,6 +2418,9 @@ export class PortProxy {
|
|||||||
console.log('PortProxy shutting down...');
|
console.log('PortProxy shutting down...');
|
||||||
this.isShuttingDown = true;
|
this.isShuttingDown = true;
|
||||||
|
|
||||||
|
// Stop the session cleanup timer
|
||||||
|
stopSessionCleanupTimer();
|
||||||
|
|
||||||
// Stop accepting new connections
|
// Stop accepting new connections
|
||||||
const closeServerPromises: Promise<void>[] = this.netServers.map(
|
const closeServerPromises: Promise<void>[] = this.netServers.map(
|
||||||
(server) =>
|
(server) =>
|
||||||
|
Reference in New Issue
Block a user