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:
Juergen Kunz
2025-07-21 19:40:01 +00:00
parent c84947068c
commit d47b048517
14 changed files with 1620 additions and 127 deletions

View File

@@ -195,4 +195,11 @@ export interface IConnectionRecord {
// NFTables tracking
nftablesHandled?: boolean; // Whether this connection is being handled by NFTables at kernel level
// HTTP-specific information (extracted from protocol detection)
httpInfo?: {
method?: string;
path?: string;
headers?: Record<string, string>;
};
}

View File

@@ -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}`, {

View File

@@ -1,5 +1,6 @@
import * as plugins from '../../plugins.js';
import { SniHandler } from '../../tls/sni/sni-handler.js';
import { ProtocolDetector, TlsDetector } from '../../detection/index.js';
import type { SmartProxy } from './smart-proxy.js';
/**

View File

@@ -21,6 +21,7 @@
import * as plugins from '../../../plugins.js';
import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTarget, TPortRange, IRouteContext } from '../models/route-types.js';
import { mergeRouteConfigs } from './route-utils.js';
import { ProtocolDetector, HttpDetector } from '../../../detection/index.js';
/**
* Create an HTTP-only route configuration
@@ -956,83 +957,91 @@ export const SocketHandlers = {
/**
* HTTP redirect handler
* Now uses the centralized detection module for HTTP parsing
*/
httpRedirect: (locationTemplate: string, statusCode: number = 301) => (socket: plugins.net.Socket, context: IRouteContext) => {
let buffer = '';
const connectionId = ProtocolDetector.createConnectionId({
socketId: context.connectionId || `${Date.now()}-${Math.random()}`
});
socket.once('data', (data) => {
buffer += data.toString();
socket.once('data', async (data) => {
// Use detection module for parsing
const detectionResult = await ProtocolDetector.detectWithConnectionTracking(
data,
connectionId,
{ extractFullHeaders: false } // We only need method and path
);
const lines = buffer.split('\r\n');
const requestLine = lines[0];
const [method, path] = requestLine.split(' ');
if (detectionResult.protocol === 'http' && detectionResult.connectionInfo.path) {
const method = detectionResult.connectionInfo.method || 'GET';
const path = detectionResult.connectionInfo.path || '/';
const domain = context.domain || 'localhost';
const port = context.port;
let finalLocation = locationTemplate
.replace('{domain}', domain)
.replace('{port}', String(port))
.replace('{path}', path)
.replace('{clientIp}', context.clientIp);
const message = `Redirecting to ${finalLocation}`;
const response = [
`HTTP/1.1 ${statusCode} ${statusCode === 301 ? 'Moved Permanently' : 'Found'}`,
`Location: ${finalLocation}`,
'Content-Type: text/plain',
`Content-Length: ${message.length}`,
'Connection: close',
'',
message
].join('\r\n');
socket.write(response);
} else {
// Not a valid HTTP request, close connection
socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
}
const domain = context.domain || 'localhost';
const port = context.port;
let finalLocation = locationTemplate
.replace('{domain}', domain)
.replace('{port}', String(port))
.replace('{path}', path)
.replace('{clientIp}', context.clientIp);
const message = `Redirecting to ${finalLocation}`;
const response = [
`HTTP/1.1 ${statusCode} ${statusCode === 301 ? 'Moved Permanently' : 'Found'}`,
`Location: ${finalLocation}`,
'Content-Type: text/plain',
`Content-Length: ${message.length}`,
'Connection: close',
'',
message
].join('\r\n');
socket.write(response);
socket.end();
// Clean up detection state
ProtocolDetector.cleanupConnections();
});
},
/**
* HTTP server handler for ACME challenges and other HTTP needs
* Now uses the centralized detection module for HTTP parsing
*/
httpServer: (handler: (req: { method: string; url: string; headers: Record<string, string>; body?: string }, res: { status: (code: number) => void; header: (name: string, value: string) => void; send: (data: string) => void; end: () => void }) => void) => (socket: plugins.net.Socket, context: IRouteContext) => {
let buffer = '';
let requestParsed = false;
const connectionId = ProtocolDetector.createConnectionId({
socketId: context.connectionId || `${Date.now()}-${Math.random()}`
});
socket.on('data', (data) => {
const processData = async (data: Buffer) => {
if (requestParsed) return; // Only handle the first request
buffer += data.toString();
// Use HttpDetector for parsing
const detectionResult = await ProtocolDetector.detectWithConnectionTracking(
data,
connectionId,
{ extractFullHeaders: true }
);
// Check if we have a complete HTTP request
const headerEndIndex = buffer.indexOf('\r\n\r\n');
if (headerEndIndex === -1) return; // Need more data
requestParsed = true;
// Parse the HTTP request
const headerPart = buffer.substring(0, headerEndIndex);
const bodyPart = buffer.substring(headerEndIndex + 4);
const lines = headerPart.split('\r\n');
const [method, url] = lines[0].split(' ');
const headers: Record<string, string> = {};
for (let i = 1; i < lines.length; i++) {
const colonIndex = lines[i].indexOf(':');
if (colonIndex > 0) {
const name = lines[i].substring(0, colonIndex).trim().toLowerCase();
const value = lines[i].substring(colonIndex + 1).trim();
headers[name] = value;
}
if (detectionResult.protocol !== 'http' || !detectionResult.isComplete) {
// Not a complete HTTP request yet
return;
}
// Create request object
requestParsed = true;
const connInfo = detectionResult.connectionInfo;
// Create request object from detection result
const req = {
method: method || 'GET',
url: url || '/',
headers,
body: bodyPart
method: connInfo.method || 'GET',
url: connInfo.path || '/',
headers: connInfo.headers || {},
body: detectionResult.remainingBuffer?.toString() || ''
};
// Create response object
@@ -1093,13 +1102,20 @@ export const SocketHandlers = {
res.send('Internal Server Error');
}
}
});
};
socket.on('data', processData);
socket.on('error', () => {
if (!requestParsed) {
socket.end();
}
});
socket.on('close', () => {
// Clean up detection state
ProtocolDetector.cleanupConnections();
});
}
};