Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
6c1efc1dc0 | |||
cad0e6a2b2 | |||
794e1292e5 | |||
ee79f9ab7c | |||
107bc3b50b | |||
97982976c8 | |||
fe60f88746 | |||
252a987344 |
28
changelog.md
28
changelog.md
@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-03-15 - 4.1.4 - fix(ConnectionHandler)
|
||||
Refactor ConnectionHandler code formatting for improved readability and consistency in log messages and whitespace handling
|
||||
|
||||
- Standardized indentation and spacing in method signatures and log statements
|
||||
- Aligned inline comments and string concatenations for clarity
|
||||
- Minor refactoring of parameter formatting without changing functionality
|
||||
|
||||
## 2025-03-15 - 4.1.3 - fix(connectionhandler)
|
||||
Improve handling of TLS ClientHello messages when allowSessionTicket is disabled and no SNI is provided by sending a warning alert (unrecognized_name, code 0x70) with a proper callback and delay to ensure the alert is transmitted before closing the connection.
|
||||
|
||||
- Replace the fatal alert (0x02/0x40) with a warning alert (0x01/0x70) to notify clients to send SNI.
|
||||
- Use socket.write callback to wait 100ms after sending the alert before terminating the connection.
|
||||
- Remove the previous short (50ms) delay in favor of a more reliable delay mechanism before cleanup.
|
||||
|
||||
## 2025-03-15 - 4.1.2 - fix(connectionhandler)
|
||||
Send proper TLS alert before terminating connections when SNI is missing and session tickets are disallowed.
|
||||
|
||||
- Added logic to transmit a fatal TLS alert (Handshake Failure) before closing the connection when no SNI is present with allowSessionTicket=false.
|
||||
- Introduced a slight 50ms delay after sending the alert to ensure the client receives the alert properly.
|
||||
- Applied these changes both for the initial ClientHello and when handling subsequent TLS data.
|
||||
|
||||
## 2025-03-15 - 4.1.1 - fix(tls)
|
||||
Enforce strict SNI handling in TLS connections by terminating ClientHello messages lacking SNI when session tickets are disallowed and removing legacy session cache code.
|
||||
|
||||
- In classes.pp.connectionhandler.ts, if allowSessionTicket is false and no SNI is extracted from a ClientHello, the connection is terminated to force a new handshake with SNI.
|
||||
- In classes.pp.snihandler.ts, removed session cache and related cleanup functions used for tab reactivation, simplifying SNI extraction logic.
|
||||
- Improved logging in TLS processing to aid in diagnosing handshake and session resumption issues.
|
||||
|
||||
## 2025-03-14 - 4.1.0 - feat(SniHandler)
|
||||
Enhance SNI extraction to support session caching and tab reactivation by adding session cache initialization, cleanup and helper methods. Update processTlsPacket to use cached SNI for session resumption and connection racing scenarios.
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "4.1.0",
|
||||
"version": "4.1.4",
|
||||
"private": false,
|
||||
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.",
|
||||
"main": "dist_ts/index.js",
|
||||
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '4.1.0',
|
||||
version: '4.1.4',
|
||||
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.'
|
||||
}
|
||||
|
@ -1,5 +1,9 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import type { IConnectionRecord, IDomainConfig, IPortProxySettings } from './classes.pp.interfaces.js';
|
||||
import type {
|
||||
IConnectionRecord,
|
||||
IDomainConfig,
|
||||
IPortProxySettings,
|
||||
} from './classes.pp.interfaces.js';
|
||||
import { ConnectionManager } from './classes.pp.connectionmanager.js';
|
||||
import { SecurityManager } from './classes.pp.securitymanager.js';
|
||||
import { DomainConfigManager } from './classes.pp.domainconfigmanager.js';
|
||||
@ -73,8 +77,8 @@ export class ConnectionHandler {
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
console.log(
|
||||
`[${connectionId}] New connection from ${remoteIP} on port ${localPort}. ` +
|
||||
`Keep-Alive: ${record.hasKeepAlive ? 'Enabled' : 'Disabled'}. ` +
|
||||
`Active connections: ${this.connectionManager.getConnectionCount()}`
|
||||
`Keep-Alive: ${record.hasKeepAlive ? 'Enabled' : 'Disabled'}. ` +
|
||||
`Active connections: ${this.connectionManager.getConnectionCount()}`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
@ -94,7 +98,10 @@ export class ConnectionHandler {
|
||||
/**
|
||||
* Handle a connection that should be forwarded to NetworkProxy
|
||||
*/
|
||||
private handleNetworkProxyConnection(socket: plugins.net.Socket, record: IConnectionRecord): void {
|
||||
private handleNetworkProxyConnection(
|
||||
socket: plugins.net.Socket,
|
||||
record: IConnectionRecord
|
||||
): void {
|
||||
const connectionId = record.id;
|
||||
let initialDataReceived = false;
|
||||
|
||||
@ -104,7 +111,7 @@ export class ConnectionHandler {
|
||||
console.log(
|
||||
`[${connectionId}] Initial data warning (${this.settings.initialDataTimeout}ms) for connection from ${record.remoteIP}`
|
||||
);
|
||||
|
||||
|
||||
// Add a grace period instead of immediate termination
|
||||
setTimeout(() => {
|
||||
if (!initialDataReceived) {
|
||||
@ -144,7 +151,7 @@ export class ConnectionHandler {
|
||||
if (!this.tlsManager.isTlsHandshake(chunk) && localPort === 443) {
|
||||
console.log(
|
||||
`[${connectionId}] Non-TLS connection detected on port 443. ` +
|
||||
`Terminating connection - only TLS traffic is allowed on standard HTTPS port.`
|
||||
`Terminating connection - only TLS traffic is allowed on standard HTTPS port.`
|
||||
);
|
||||
if (record.incomingTerminationReason === null) {
|
||||
record.incomingTerminationReason = 'non_tls_blocked';
|
||||
@ -159,8 +166,8 @@ export class ConnectionHandler {
|
||||
if (this.tlsManager.isTlsHandshake(chunk)) {
|
||||
record.isTLS = true;
|
||||
|
||||
// Check session tickets if they're disabled
|
||||
if (this.settings.allowSessionTicket === false && this.tlsManager.isClientHello(chunk)) {
|
||||
// Check for ClientHello to extract SNI - but don't enforce it for NetworkProxy
|
||||
if (this.tlsManager.isClientHello(chunk)) {
|
||||
// Create connection info for SNI extraction
|
||||
const connInfo = {
|
||||
sourceIp: record.remoteIP,
|
||||
@ -169,14 +176,14 @@ export class ConnectionHandler {
|
||||
destPort: socket.localPort || 0,
|
||||
};
|
||||
|
||||
// Extract SNI for domain-specific NetworkProxy handling
|
||||
// Extract SNI for domain-specific NetworkProxy handling if available
|
||||
const serverName = this.tlsManager.extractSNI(chunk, connInfo);
|
||||
|
||||
// For NetworkProxy connections, we'll allow session tickets even without SNI
|
||||
// We'll only use the serverName if available to determine the specific NetworkProxy port
|
||||
if (serverName) {
|
||||
// If we got an SNI, check for domain-specific NetworkProxy settings
|
||||
const domainConfig = this.domainConfigManager.findDomainConfig(serverName);
|
||||
|
||||
// Save domain config and SNI in connection record
|
||||
const domainConfig = this.domainConfigManager.findDomainConfig(serverName);
|
||||
record.domainConfig = domainConfig;
|
||||
record.lockedDomain = serverName;
|
||||
|
||||
@ -201,6 +208,14 @@ export class ConnectionHandler {
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
this.settings.allowSessionTicket === false &&
|
||||
this.settings.enableDetailedLogging
|
||||
) {
|
||||
// Log that we're allowing a session resumption without SNI for NetworkProxy
|
||||
console.log(
|
||||
`[${connectionId}] Allowing session resumption without SNI for NetworkProxy forwarding`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -215,14 +230,10 @@ export class ConnectionHandler {
|
||||
);
|
||||
} else {
|
||||
// If not TLS, use normal direct connection
|
||||
console.log(`[${connectionId}] Non-TLS connection on NetworkProxy port ${record.localPort}`);
|
||||
this.setupDirectConnection(
|
||||
socket,
|
||||
record,
|
||||
undefined,
|
||||
undefined,
|
||||
chunk
|
||||
console.log(
|
||||
`[${connectionId}] Non-TLS connection on NetworkProxy port ${record.localPort}`
|
||||
);
|
||||
this.setupDirectConnection(socket, record, undefined, undefined, chunk);
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -255,7 +266,7 @@ export class ConnectionHandler {
|
||||
console.log(
|
||||
`[${connectionId}] Initial data warning (${this.settings.initialDataTimeout}ms) for connection from ${record.remoteIP}`
|
||||
);
|
||||
|
||||
|
||||
// Add a grace period instead of immediate termination
|
||||
setTimeout(() => {
|
||||
if (!initialDataReceived) {
|
||||
@ -340,14 +351,13 @@ export class ConnectionHandler {
|
||||
record.domainConfig = domainConfig;
|
||||
|
||||
// Check if this domain should use NetworkProxy (domain-specific setting)
|
||||
if (domainConfig &&
|
||||
this.domainConfigManager.shouldUseNetworkProxy(domainConfig) &&
|
||||
this.networkProxyBridge.getNetworkProxy()) {
|
||||
|
||||
if (
|
||||
domainConfig &&
|
||||
this.domainConfigManager.shouldUseNetworkProxy(domainConfig) &&
|
||||
this.networkProxyBridge.getNetworkProxy()
|
||||
) {
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
console.log(
|
||||
`[${connectionId}] Domain ${serverName} is configured to use NetworkProxy`
|
||||
);
|
||||
console.log(`[${connectionId}] Domain ${serverName} is configured to use NetworkProxy`);
|
||||
}
|
||||
|
||||
const networkProxyPort = this.domainConfigManager.getNetworkProxyPort(domainConfig);
|
||||
@ -369,23 +379,24 @@ export class ConnectionHandler {
|
||||
// IP validation
|
||||
if (domainConfig) {
|
||||
const ipRules = this.domainConfigManager.getEffectiveIPRules(domainConfig);
|
||||
|
||||
|
||||
// Skip IP validation if allowedIPs is empty
|
||||
if (
|
||||
domainConfig.allowedIPs.length > 0 &&
|
||||
!this.securityManager.isIPAuthorized(record.remoteIP, ipRules.allowedIPs, ipRules.blockedIPs)
|
||||
!this.securityManager.isIPAuthorized(
|
||||
record.remoteIP,
|
||||
ipRules.allowedIPs,
|
||||
ipRules.blockedIPs
|
||||
)
|
||||
) {
|
||||
return rejectIncomingConnection(
|
||||
'rejected',
|
||||
`Connection rejected: IP ${record.remoteIP} not allowed for domain ${domainConfig.domains.join(
|
||||
', '
|
||||
)}`
|
||||
`Connection rejected: IP ${
|
||||
record.remoteIP
|
||||
} not allowed for domain ${domainConfig.domains.join(', ')}`
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
this.settings.defaultAllowedIPs &&
|
||||
this.settings.defaultAllowedIPs.length > 0
|
||||
) {
|
||||
} else if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) {
|
||||
if (
|
||||
!this.securityManager.isIPAuthorized(
|
||||
record.remoteIP,
|
||||
@ -452,28 +463,36 @@ export class ConnectionHandler {
|
||||
} else {
|
||||
// Attempt to find a matching forced domain config based on the local port.
|
||||
const forcedDomain = this.domainConfigManager.findDomainConfigForPort(localPort);
|
||||
|
||||
|
||||
if (forcedDomain) {
|
||||
const ipRules = this.domainConfigManager.getEffectiveIPRules(forcedDomain);
|
||||
|
||||
if (!this.securityManager.isIPAuthorized(record.remoteIP, ipRules.allowedIPs, ipRules.blockedIPs)) {
|
||||
|
||||
if (
|
||||
!this.securityManager.isIPAuthorized(
|
||||
record.remoteIP,
|
||||
ipRules.allowedIPs,
|
||||
ipRules.blockedIPs
|
||||
)
|
||||
) {
|
||||
console.log(
|
||||
`[${connectionId}] Connection from ${record.remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join(
|
||||
`[${connectionId}] Connection from ${
|
||||
record.remoteIP
|
||||
} rejected: IP not allowed for domain ${forcedDomain.domains.join(
|
||||
', '
|
||||
)} on port ${localPort}.`
|
||||
);
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
console.log(
|
||||
`[${connectionId}] Port-based connection from ${record.remoteIP} on port ${localPort} matched domain ${forcedDomain.domains.join(
|
||||
', '
|
||||
)}.`
|
||||
`[${connectionId}] Port-based connection from ${
|
||||
record.remoteIP
|
||||
} on port ${localPort} matched domain ${forcedDomain.domains.join(', ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
setupConnection('', undefined, forcedDomain, localPort);
|
||||
return;
|
||||
}
|
||||
@ -491,14 +510,14 @@ export class ConnectionHandler {
|
||||
clearTimeout(initialTimeout);
|
||||
initialTimeout = null;
|
||||
}
|
||||
|
||||
|
||||
initialDataReceived = true;
|
||||
|
||||
|
||||
// Block non-TLS connections on port 443
|
||||
if (!this.tlsManager.isTlsHandshake(chunk) && localPort === 443) {
|
||||
console.log(
|
||||
`[${connectionId}] Non-TLS connection detected on port 443 in SNI handler. ` +
|
||||
`Terminating connection - only TLS traffic is allowed on standard HTTPS port.`
|
||||
`Terminating connection - only TLS traffic is allowed on standard HTTPS port.`
|
||||
);
|
||||
if (record.incomingTerminationReason === null) {
|
||||
record.incomingTerminationReason = 'non_tls_blocked';
|
||||
@ -531,6 +550,67 @@ export class ConnectionHandler {
|
||||
|
||||
// Extract SNI
|
||||
serverName = this.tlsManager.extractSNI(chunk, connInfo) || '';
|
||||
|
||||
// If allowSessionTicket is false and this is a ClientHello with no SNI, terminate the connection
|
||||
if (
|
||||
this.settings.allowSessionTicket === false &&
|
||||
this.tlsManager.isClientHello(chunk) &&
|
||||
!serverName
|
||||
) {
|
||||
// Always block ClientHello without SNI when allowSessionTicket is false
|
||||
console.log(
|
||||
`[${connectionId}] No SNI detected in ClientHello and allowSessionTicket=false. ` +
|
||||
`Terminating connection to force new TLS handshake with SNI.`
|
||||
);
|
||||
|
||||
// Send a proper TLS alert before ending the connection
|
||||
// Using "unrecognized_name" (112) alert which is a warning level alert (1)
|
||||
// that encourages clients to retry with proper SNI
|
||||
const alertData = Buffer.from([
|
||||
0x15, // Alert record type
|
||||
0x03,
|
||||
0x03, // TLS 1.2 version
|
||||
0x00,
|
||||
0x02, // Length
|
||||
0x01, // Warning alert level (not fatal)
|
||||
0x70, // unrecognized_name alert (code 112)
|
||||
]);
|
||||
|
||||
try {
|
||||
socket.write(alertData, () => {
|
||||
// Only close the socket after we're sure the alert was sent
|
||||
// Give the alert time to be processed by the client
|
||||
setTimeout(() => {
|
||||
socket.end();
|
||||
|
||||
// Ensure complete cleanup happens a bit later
|
||||
setTimeout(() => {
|
||||
if (!socket.destroyed) {
|
||||
socket.destroy();
|
||||
}
|
||||
this.connectionManager.cleanupConnection(
|
||||
record,
|
||||
'session_ticket_blocked_no_sni'
|
||||
);
|
||||
}, 100);
|
||||
}, 100);
|
||||
});
|
||||
} catch (err) {
|
||||
// If we can't send the alert, fall back to immediate termination
|
||||
socket.end();
|
||||
this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni');
|
||||
}
|
||||
|
||||
if (record.incomingTerminationReason === null) {
|
||||
record.incomingTerminationReason = 'session_ticket_blocked_no_sni';
|
||||
this.connectionManager.incrementTerminationStat(
|
||||
'incoming',
|
||||
'session_ticket_blocked_no_sni'
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Lock the connection to the negotiated SNI.
|
||||
@ -577,23 +657,21 @@ export class ConnectionHandler {
|
||||
overridePort?: number
|
||||
): void {
|
||||
const connectionId = record.id;
|
||||
|
||||
|
||||
// Determine target host
|
||||
const targetHost = domainConfig
|
||||
? this.domainConfigManager.getTargetIP(domainConfig)
|
||||
const targetHost = domainConfig
|
||||
? this.domainConfigManager.getTargetIP(domainConfig)
|
||||
: this.settings.targetIP!;
|
||||
|
||||
|
||||
// Determine target port
|
||||
const targetPort = overridePort !== undefined
|
||||
? overridePort
|
||||
: this.settings.toPort;
|
||||
|
||||
const targetPort = overridePort !== undefined ? overridePort : this.settings.toPort;
|
||||
|
||||
// Setup connection options
|
||||
const connectionOptions: plugins.net.NetConnectOpts = {
|
||||
host: targetHost,
|
||||
port: targetPort,
|
||||
};
|
||||
|
||||
|
||||
// Preserve source IP if configured
|
||||
if (this.settings.preserveSourceIP) {
|
||||
connectionOptions.localAddress = record.remoteIP.replace('::ffff:', '');
|
||||
@ -850,18 +928,20 @@ export class ConnectionHandler {
|
||||
|
||||
// Process any remaining data in the queue before switching to piping
|
||||
processDataQueue();
|
||||
|
||||
|
||||
// Set up piping immediately
|
||||
pipingEstablished = true;
|
||||
|
||||
|
||||
// Flush all pending data to target
|
||||
if (record.pendingData.length > 0) {
|
||||
const combinedData = Buffer.concat(record.pendingData);
|
||||
|
||||
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
console.log(`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`);
|
||||
console.log(
|
||||
`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Write pending data immediately
|
||||
targetSocket.write(combinedData, (err) => {
|
||||
if (err) {
|
||||
@ -869,19 +949,19 @@ export class ConnectionHandler {
|
||||
return this.connectionManager.initiateCleanupOnce(record, 'write_error');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Clear the buffer now that we've processed it
|
||||
record.pendingData = [];
|
||||
record.pendingDataSize = 0;
|
||||
}
|
||||
|
||||
|
||||
// Setup piping in both directions without any delays
|
||||
socket.pipe(targetSocket);
|
||||
targetSocket.pipe(socket);
|
||||
|
||||
|
||||
// Resume the socket to ensure data flows
|
||||
socket.resume();
|
||||
|
||||
|
||||
// Process any data that might be queued in the interim
|
||||
if (dataQueue.length > 0) {
|
||||
// Write any remaining queued data directly to the target socket
|
||||
@ -892,7 +972,7 @@ export class ConnectionHandler {
|
||||
dataQueue.length = 0;
|
||||
queueSize = 0;
|
||||
}
|
||||
|
||||
|
||||
if (this.settings.enableDetailedLogging) {
|
||||
console.log(
|
||||
`[${connectionId}] Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` +
|
||||
@ -957,15 +1037,12 @@ export class ConnectionHandler {
|
||||
}
|
||||
|
||||
// Set connection timeout
|
||||
record.cleanupTimer = this.timeoutManager.setupConnectionTimeout(
|
||||
record,
|
||||
(record, reason) => {
|
||||
console.log(
|
||||
`[${connectionId}] Connection from ${record.remoteIP} exceeded max lifetime, forcing cleanup.`
|
||||
);
|
||||
this.connectionManager.initiateCleanupOnce(record, reason);
|
||||
}
|
||||
);
|
||||
record.cleanupTimer = this.timeoutManager.setupConnectionTimeout(record, (record, reason) => {
|
||||
console.log(
|
||||
`[${connectionId}] Connection from ${record.remoteIP} exceeded max lifetime, forcing cleanup.`
|
||||
);
|
||||
this.connectionManager.initiateCleanupOnce(record, reason);
|
||||
});
|
||||
|
||||
// Mark TLS handshake as complete for TLS connections
|
||||
if (record.isTLS) {
|
||||
@ -979,4 +1056,4 @@ export class ConnectionHandler {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -22,135 +22,6 @@ export class SniHandler {
|
||||
private static fragmentedBuffers: Map<string, Buffer> = new Map();
|
||||
private static fragmentTimeout: number = 1000; // ms to wait for fragments before cleanup
|
||||
|
||||
// Session tracking for tab reactivation scenarios
|
||||
private static sessionCache: Map<
|
||||
string,
|
||||
{
|
||||
sni: string;
|
||||
timestamp: number;
|
||||
clientRandom?: Buffer;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
// Longer timeout for session cache (24 hours by default)
|
||||
private static sessionCacheTimeout: number = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
|
||||
|
||||
// Cleanup interval for session cache (run every hour)
|
||||
private static sessionCleanupInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the session cache cleanup mechanism.
|
||||
* This should be called during application startup.
|
||||
*/
|
||||
public static initSessionCacheCleanup(): void {
|
||||
if (this.sessionCleanupInterval === null) {
|
||||
this.sessionCleanupInterval = setInterval(() => {
|
||||
this.cleanupSessionCache();
|
||||
}, 60 * 60 * 1000); // Run every hour
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired entries from the session cache
|
||||
*/
|
||||
private static cleanupSessionCache(): void {
|
||||
const now = Date.now();
|
||||
const expiredKeys: string[] = [];
|
||||
|
||||
this.sessionCache.forEach((session, key) => {
|
||||
if (now - session.timestamp > this.sessionCacheTimeout) {
|
||||
expiredKeys.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
expiredKeys.forEach((key) => {
|
||||
this.sessionCache.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a client identity key for session tracking
|
||||
* Uses source IP and optional client random for uniqueness
|
||||
*
|
||||
* @param sourceIp - Client IP address
|
||||
* @param clientRandom - Optional TLS client random value
|
||||
* @returns A string key for the session cache
|
||||
*/
|
||||
private static createClientKey(sourceIp: string, clientRandom?: Buffer): string {
|
||||
if (clientRandom) {
|
||||
// If we have the client random, use it for more precise tracking
|
||||
return `${sourceIp}:${clientRandom.toString('hex')}`;
|
||||
}
|
||||
// Fall back to just IP-based tracking
|
||||
return sourceIp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store SNI information in the session cache
|
||||
*
|
||||
* @param sourceIp - Client IP address
|
||||
* @param sni - The extracted SNI value
|
||||
* @param clientRandom - Optional TLS client random value
|
||||
*/
|
||||
private static cacheSession(sourceIp: string, sni: string, clientRandom?: Buffer): void {
|
||||
const key = this.createClientKey(sourceIp, clientRandom);
|
||||
this.sessionCache.set(key, {
|
||||
sni,
|
||||
timestamp: Date.now(),
|
||||
clientRandom,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve SNI information from the session cache
|
||||
*
|
||||
* @param sourceIp - Client IP address
|
||||
* @param clientRandom - Optional TLS client random value
|
||||
* @returns The cached SNI or undefined if not found
|
||||
*/
|
||||
private static getCachedSession(sourceIp: string, clientRandom?: Buffer): string | undefined {
|
||||
// Try with client random first for precision
|
||||
if (clientRandom) {
|
||||
const preciseKey = this.createClientKey(sourceIp, clientRandom);
|
||||
const preciseSession = this.sessionCache.get(preciseKey);
|
||||
if (preciseSession) {
|
||||
return preciseSession.sni;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to IP-only lookup
|
||||
const ipKey = this.createClientKey(sourceIp);
|
||||
const session = this.sessionCache.get(ipKey);
|
||||
if (session) {
|
||||
// Update the timestamp to keep the session alive
|
||||
session.timestamp = Date.now();
|
||||
return session.sni;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the client random value from a ClientHello message
|
||||
*
|
||||
* @param buffer - The buffer containing the ClientHello
|
||||
* @returns The 32-byte client random or undefined if extraction fails
|
||||
*/
|
||||
private static extractClientRandom(buffer: Buffer): Buffer | undefined {
|
||||
try {
|
||||
if (!this.isClientHello(buffer) || buffer.length < 46) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// In a ClientHello message, the client random starts at position 11
|
||||
// after record header (5 bytes), handshake type (1 byte),
|
||||
// handshake length (3 bytes), and client version (2 bytes)
|
||||
return buffer.slice(11, 11 + 32);
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a buffer contains a TLS handshake message (record type 22)
|
||||
* @param buffer - The buffer to check
|
||||
@ -1172,7 +1043,6 @@ export class SniHandler {
|
||||
* 4. Fragmented ClientHello messages
|
||||
* 5. TLS 1.3 Early Data (0-RTT)
|
||||
* 6. Chrome's connection racing behaviors
|
||||
* 7. Tab reactivation patterns with session cache
|
||||
*
|
||||
* @param buffer - The buffer containing the TLS ClientHello message
|
||||
* @param connectionInfo - Optional connection information for fragment handling
|
||||
@ -1235,19 +1105,10 @@ export class SniHandler {
|
||||
const standardSni = this.extractSNI(processBuffer, enableLogging);
|
||||
if (standardSni) {
|
||||
log(`Found standard SNI: ${standardSni}`);
|
||||
|
||||
// If we extracted a standard SNI, cache it for future use
|
||||
if (connectionInfo?.sourceIp) {
|
||||
const clientRandom = this.extractClientRandom(processBuffer);
|
||||
this.cacheSession(connectionInfo.sourceIp, standardSni, clientRandom);
|
||||
log(`Cached SNI for future reference: ${standardSni}`);
|
||||
}
|
||||
|
||||
return standardSni;
|
||||
}
|
||||
|
||||
// Check for session resumption when standard SNI extraction fails
|
||||
// This may help in chained proxy scenarios
|
||||
if (this.isClientHello(processBuffer)) {
|
||||
const resumptionInfo = this.hasSessionResumption(processBuffer, enableLogging);
|
||||
|
||||
@ -1258,31 +1119,11 @@ export class SniHandler {
|
||||
const pskSni = this.extractSNIFromPSKExtension(processBuffer, enableLogging);
|
||||
if (pskSni) {
|
||||
log(`Extracted SNI from PSK extension: ${pskSni}`);
|
||||
|
||||
// Cache this SNI
|
||||
if (connectionInfo?.sourceIp) {
|
||||
const clientRandom = this.extractClientRandom(processBuffer);
|
||||
this.cacheSession(connectionInfo.sourceIp, pskSni, clientRandom);
|
||||
}
|
||||
|
||||
return pskSni;
|
||||
}
|
||||
|
||||
// If session resumption has SNI in a non-standard location,
|
||||
// we need to apply heuristics
|
||||
if (connectionInfo?.sourceIp) {
|
||||
const cachedSni = this.getCachedSession(connectionInfo.sourceIp);
|
||||
if (cachedSni) {
|
||||
log(`Using cached SNI for session resumption: ${cachedSni}`);
|
||||
return cachedSni;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try tab reactivation and other recovery methods...
|
||||
// (existing code remains unchanged)
|
||||
|
||||
// Log detailed info about the ClientHello when SNI extraction fails
|
||||
if (this.isClientHello(processBuffer) && enableLogging) {
|
||||
log(`SNI extraction failed for ClientHello. Buffer details:`);
|
||||
@ -1303,7 +1144,6 @@ export class SniHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Existing code for fallback methods continues...
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -1321,7 +1161,6 @@ export class SniHandler {
|
||||
* @param cachedSni - Optional cached SNI from previous connections (for racing detection)
|
||||
* @returns The extracted server name or undefined if not found or more data needed
|
||||
*/
|
||||
|
||||
public static processTlsPacket(
|
||||
buffer: Buffer,
|
||||
connectionInfo: {
|
||||
@ -1357,19 +1196,12 @@ export class SniHandler {
|
||||
|
||||
// Handle application data with cached SNI (for connection racing)
|
||||
if (this.isTlsApplicationData(buffer)) {
|
||||
// First check if explicit cachedSni was provided
|
||||
// If explicit cachedSni was provided, use it
|
||||
if (cachedSni) {
|
||||
log(`Using provided cached SNI for application data: ${cachedSni}`);
|
||||
return cachedSni;
|
||||
}
|
||||
|
||||
// Otherwise check our session cache
|
||||
const sessionCachedSni = this.getCachedSession(connectionInfo.sourceIp);
|
||||
if (sessionCachedSni) {
|
||||
log(`Using session-cached SNI for application data: ${sessionCachedSni}`);
|
||||
return sessionCachedSni;
|
||||
}
|
||||
|
||||
log('Application data packet without cached SNI, cannot determine hostname');
|
||||
return undefined;
|
||||
}
|
||||
@ -1385,9 +1217,6 @@ export class SniHandler {
|
||||
const standardSni = this.extractSNI(buffer, enableLogging);
|
||||
if (standardSni) {
|
||||
log(`Found standard SNI in session resumption: ${standardSni}`);
|
||||
|
||||
// Cache this SNI
|
||||
this.cacheSession(connectionInfo.sourceIp, standardSni);
|
||||
return standardSni;
|
||||
}
|
||||
|
||||
@ -1396,7 +1225,6 @@ export class SniHandler {
|
||||
const pskSni = this.extractSNIFromPSKExtension(buffer, enableLogging);
|
||||
if (pskSni) {
|
||||
log(`Extracted SNI from PSK extension: ${pskSni}`);
|
||||
this.cacheSession(connectionInfo.sourceIp, pskSni);
|
||||
return pskSni;
|
||||
}
|
||||
|
||||
@ -1430,13 +1258,6 @@ export class SniHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// If we still don't have SNI, check for cached sessions
|
||||
const cachedSni = this.getCachedSession(connectionInfo.sourceIp);
|
||||
if (cachedSni) {
|
||||
log(`Using cached SNI for session resumption: ${cachedSni}`);
|
||||
return cachedSni;
|
||||
}
|
||||
|
||||
log(`Session resumption without extractable SNI`);
|
||||
// If allowSessionTicket=false, should be rejected by caller
|
||||
}
|
||||
@ -1451,16 +1272,7 @@ export class SniHandler {
|
||||
}
|
||||
|
||||
// If we couldn't extract an SNI, check if this is a valid ClientHello
|
||||
// If it is, but we couldn't get an SNI, it might be a fragment or
|
||||
// a connection race situation
|
||||
if (this.isClientHello(buffer)) {
|
||||
// Check if we have a cached session for this IP
|
||||
const sessionCachedSni = this.getCachedSession(connectionInfo.sourceIp);
|
||||
if (sessionCachedSni) {
|
||||
log(`Using session cache for ClientHello without SNI: ${sessionCachedSni}`);
|
||||
return sessionCachedSni;
|
||||
}
|
||||
|
||||
log('Valid ClientHello detected, but no SNI extracted - might need more data');
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user