feat(detection): add centralized protocol detection module
- Created ts/detection module for unified protocol detection - Implemented TLS and HTTP detectors with fragmentation support - Moved TLS detection logic from existing code to centralized module - Updated RouteConnectionHandler to use ProtocolDetector for both TLS and HTTP - Refactored ACME HTTP parsing to use detection module - Added comprehensive tests for detection functionality - Eliminated duplicate protocol detection code across codebase This centralizes all non-destructive protocol detection into a single module, improving code organization and reducing duplication between ACME and routing.
This commit is contained in:
@@ -10,6 +10,7 @@ import { WrappedSocket } from '../../core/models/wrapped-socket.js';
|
||||
import { getUnderlyingSocket } from '../../core/models/socket-types.js';
|
||||
import { ProxyProtocolParser } from '../../core/utils/proxy-protocol.js';
|
||||
import type { SmartProxy } from './smart-proxy.js';
|
||||
import { ProtocolDetector } from '../../detection/index.js';
|
||||
|
||||
/**
|
||||
* Handles new connection processing and setup logic with support for route-based configuration
|
||||
@@ -301,11 +302,27 @@ export class RouteConnectionHandler {
|
||||
});
|
||||
|
||||
// Handler for processing initial data (after potential PROXY protocol)
|
||||
const processInitialData = (chunk: Buffer) => {
|
||||
const processInitialData = async (chunk: Buffer) => {
|
||||
// Use ProtocolDetector to identify protocol
|
||||
const connectionId = ProtocolDetector.createConnectionId({
|
||||
sourceIp: record.remoteIP,
|
||||
sourcePort: socket.remotePort,
|
||||
destIp: socket.localAddress,
|
||||
destPort: socket.localPort,
|
||||
socketId: record.id
|
||||
});
|
||||
|
||||
const detectionResult = await ProtocolDetector.detectWithConnectionTracking(
|
||||
chunk,
|
||||
connectionId,
|
||||
{ extractFullHeaders: false } // Only extract essential info for routing
|
||||
);
|
||||
|
||||
// Block non-TLS connections on port 443
|
||||
if (!this.smartProxy.tlsManager.isTlsHandshake(chunk) && localPort === 443) {
|
||||
logger.log('warn', `Non-TLS connection ${connectionId} detected on port 443. Terminating connection - only TLS traffic is allowed on standard HTTPS port.`, {
|
||||
connectionId,
|
||||
if (localPort === 443 && detectionResult.protocol !== 'tls') {
|
||||
logger.log('warn', `Non-TLS connection ${record.id} detected on port 443. Terminating connection - only TLS traffic is allowed on standard HTTPS port.`, {
|
||||
connectionId: record.id,
|
||||
detectedProtocol: detectionResult.protocol,
|
||||
message: 'Terminating connection - only TLS traffic is allowed on standard HTTPS port.',
|
||||
component: 'route-handler'
|
||||
});
|
||||
@@ -318,71 +335,78 @@ export class RouteConnectionHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this looks like a TLS handshake
|
||||
// Extract domain and protocol info
|
||||
let serverName = '';
|
||||
if (this.smartProxy.tlsManager.isTlsHandshake(chunk)) {
|
||||
if (detectionResult.protocol === 'tls') {
|
||||
record.isTLS = true;
|
||||
serverName = detectionResult.connectionInfo.domain || '';
|
||||
|
||||
// Lock the connection to the negotiated SNI
|
||||
record.lockedDomain = serverName;
|
||||
|
||||
// Check for ClientHello to extract SNI
|
||||
if (this.smartProxy.tlsManager.isClientHello(chunk)) {
|
||||
// Create connection info for SNI extraction
|
||||
const connInfo = {
|
||||
sourceIp: record.remoteIP,
|
||||
sourcePort: socket.remotePort || 0,
|
||||
destIp: socket.localAddress || '',
|
||||
destPort: socket.localPort || 0,
|
||||
};
|
||||
|
||||
// Extract SNI
|
||||
serverName = this.smartProxy.tlsManager.extractSNI(chunk, connInfo) || '';
|
||||
|
||||
// Lock the connection to the negotiated SNI
|
||||
record.lockedDomain = serverName;
|
||||
|
||||
// Check if we should reject connections without SNI
|
||||
if (!serverName && this.smartProxy.settings.allowSessionTicket === false) {
|
||||
logger.log('warn', `No SNI detected in TLS ClientHello for connection ${connectionId}; sending TLS alert`, {
|
||||
connectionId,
|
||||
component: 'route-handler'
|
||||
});
|
||||
if (record.incomingTerminationReason === null) {
|
||||
record.incomingTerminationReason = 'session_ticket_blocked_no_sni';
|
||||
this.smartProxy.connectionManager.incrementTerminationStat(
|
||||
'incoming',
|
||||
'session_ticket_blocked_no_sni'
|
||||
);
|
||||
}
|
||||
const alert = Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x70]);
|
||||
try {
|
||||
// Count the alert bytes being sent
|
||||
record.bytesSent += alert.length;
|
||||
if (this.smartProxy.metricsCollector) {
|
||||
this.smartProxy.metricsCollector.recordBytes(record.id, 0, alert.length);
|
||||
}
|
||||
|
||||
socket.cork();
|
||||
socket.write(alert);
|
||||
socket.uncork();
|
||||
socket.end();
|
||||
} catch {
|
||||
socket.end();
|
||||
}
|
||||
this.smartProxy.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni');
|
||||
return;
|
||||
// Check if we should reject connections without SNI
|
||||
if (!serverName && this.smartProxy.settings.allowSessionTicket === false) {
|
||||
logger.log('warn', `No SNI detected in TLS ClientHello for connection ${record.id}; sending TLS alert`, {
|
||||
connectionId: record.id,
|
||||
component: 'route-handler'
|
||||
});
|
||||
if (record.incomingTerminationReason === null) {
|
||||
record.incomingTerminationReason = 'session_ticket_blocked_no_sni';
|
||||
this.smartProxy.connectionManager.incrementTerminationStat(
|
||||
'incoming',
|
||||
'session_ticket_blocked_no_sni'
|
||||
);
|
||||
}
|
||||
|
||||
if (this.smartProxy.settings.enableDetailedLogging) {
|
||||
logger.log('info', `TLS connection with SNI`, {
|
||||
connectionId,
|
||||
serverName: serverName || '(empty)',
|
||||
component: 'route-handler'
|
||||
});
|
||||
const alert = Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x70]);
|
||||
try {
|
||||
// Count the alert bytes being sent
|
||||
record.bytesSent += alert.length;
|
||||
if (this.smartProxy.metricsCollector) {
|
||||
this.smartProxy.metricsCollector.recordBytes(record.id, 0, alert.length);
|
||||
}
|
||||
|
||||
socket.cork();
|
||||
socket.write(alert);
|
||||
socket.uncork();
|
||||
socket.end();
|
||||
} catch {
|
||||
socket.end();
|
||||
}
|
||||
this.smartProxy.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.smartProxy.settings.enableDetailedLogging) {
|
||||
logger.log('info', `TLS connection with SNI`, {
|
||||
connectionId: record.id,
|
||||
serverName: serverName || '(empty)',
|
||||
component: 'route-handler'
|
||||
});
|
||||
}
|
||||
} else if (detectionResult.protocol === 'http') {
|
||||
// For HTTP, extract domain from Host header
|
||||
serverName = detectionResult.connectionInfo.domain || '';
|
||||
|
||||
// Store HTTP-specific info for later use
|
||||
record.httpInfo = {
|
||||
method: detectionResult.connectionInfo.method,
|
||||
path: detectionResult.connectionInfo.path,
|
||||
headers: detectionResult.connectionInfo.headers
|
||||
};
|
||||
|
||||
if (this.smartProxy.settings.enableDetailedLogging) {
|
||||
logger.log('info', `HTTP connection detected`, {
|
||||
connectionId: record.id,
|
||||
domain: serverName || '(no host header)',
|
||||
method: detectionResult.connectionInfo.method,
|
||||
path: detectionResult.connectionInfo.path,
|
||||
component: 'route-handler'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find the appropriate route for this connection
|
||||
this.routeConnection(socket, record, serverName, chunk);
|
||||
this.routeConnection(socket, record, serverName, chunk, detectionResult);
|
||||
};
|
||||
|
||||
// First data handler to capture initial TLS handshake or PROXY protocol
|
||||
@@ -454,7 +478,8 @@ export class RouteConnectionHandler {
|
||||
socket: plugins.net.Socket | WrappedSocket,
|
||||
record: IConnectionRecord,
|
||||
serverName: string,
|
||||
initialChunk?: Buffer
|
||||
initialChunk?: Buffer,
|
||||
detectionResult?: any // Using any temporarily to avoid circular dependency issues
|
||||
): void {
|
||||
const connectionId = record.id;
|
||||
const localPort = record.localPort;
|
||||
@@ -635,7 +660,7 @@ export class RouteConnectionHandler {
|
||||
// Handle the route based on its action type
|
||||
switch (route.action.type) {
|
||||
case 'forward':
|
||||
return this.handleForwardAction(socket, record, route, initialChunk);
|
||||
return this.handleForwardAction(socket, record, route, initialChunk, detectionResult);
|
||||
|
||||
case 'socket-handler':
|
||||
logger.log('info', `Handling socket-handler action for route ${route.name}`, {
|
||||
@@ -738,7 +763,8 @@ export class RouteConnectionHandler {
|
||||
socket: plugins.net.Socket | WrappedSocket,
|
||||
record: IConnectionRecord,
|
||||
route: IRouteConfig,
|
||||
initialChunk?: Buffer
|
||||
initialChunk?: Buffer,
|
||||
detectionResult?: any // Using any temporarily to avoid circular dependency issues
|
||||
): void {
|
||||
const connectionId = record.id;
|
||||
const action = route.action as IRouteAction;
|
||||
@@ -819,14 +845,11 @@ export class RouteConnectionHandler {
|
||||
// Create context for target selection
|
||||
const targetSelectionContext = {
|
||||
port: record.localPort,
|
||||
path: undefined, // Will be populated from HTTP headers if available
|
||||
headers: undefined, // Will be populated from HTTP headers if available
|
||||
method: undefined // Will be populated from HTTP headers if available
|
||||
path: record.httpInfo?.path,
|
||||
headers: record.httpInfo?.headers,
|
||||
method: record.httpInfo?.method
|
||||
};
|
||||
|
||||
// TODO: Extract path, headers, and method from initialChunk if it's HTTP
|
||||
// For now, we'll select based on port only
|
||||
|
||||
const selectedTarget = this.selectTarget(action.targets, targetSelectionContext);
|
||||
if (!selectedTarget) {
|
||||
logger.log('error', `No matching target found for connection ${connectionId}`, {
|
||||
|
Reference in New Issue
Block a user