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:
@@ -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>;
|
||||
};
|
||||
}
|
@@ -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}`, {
|
||||
|
@@ -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';
|
||||
|
||||
/**
|
||||
|
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
Reference in New Issue
Block a user