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:
281
ts/detection/detectors/http-detector.ts
Normal file
281
ts/detection/detectors/http-detector.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* HTTP protocol detector
|
||||
*/
|
||||
|
||||
import type { IProtocolDetector } from '../models/interfaces.js';
|
||||
import type { IDetectionResult, IDetectionOptions, IConnectionInfo, THttpMethod } from '../models/detection-types.js';
|
||||
import { extractLine, isPrintableAscii, BufferAccumulator } from '../utils/buffer-utils.js';
|
||||
import { parseHttpRequestLine, parseHttpHeaders, extractDomainFromHost, isHttpMethod } from '../utils/parser-utils.js';
|
||||
|
||||
/**
|
||||
* HTTP detector implementation
|
||||
*/
|
||||
export class HttpDetector implements IProtocolDetector {
|
||||
/**
|
||||
* Minimum bytes needed to identify HTTP method
|
||||
*/
|
||||
private static readonly MIN_HTTP_METHOD_SIZE = 3; // GET
|
||||
|
||||
/**
|
||||
* Maximum reasonable HTTP header size
|
||||
*/
|
||||
private static readonly MAX_HEADER_SIZE = 8192;
|
||||
|
||||
/**
|
||||
* Fragment tracking for incomplete headers
|
||||
*/
|
||||
private static fragmentedBuffers = new Map<string, BufferAccumulator>();
|
||||
|
||||
/**
|
||||
* Detect HTTP protocol from buffer
|
||||
*/
|
||||
detect(buffer: Buffer, options?: IDetectionOptions): IDetectionResult | null {
|
||||
// Check if buffer is too small
|
||||
if (buffer.length < HttpDetector.MIN_HTTP_METHOD_SIZE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Quick check: first bytes should be printable ASCII
|
||||
if (!isPrintableAscii(buffer, Math.min(20, buffer.length))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to extract the first line
|
||||
const firstLineResult = extractLine(buffer, 0);
|
||||
if (!firstLineResult) {
|
||||
// No complete line yet
|
||||
return {
|
||||
protocol: 'http',
|
||||
connectionInfo: { protocol: 'http' },
|
||||
isComplete: false,
|
||||
bytesNeeded: buffer.length + 100 // Estimate
|
||||
};
|
||||
}
|
||||
|
||||
// Parse the request line
|
||||
const requestLine = parseHttpRequestLine(firstLineResult.line);
|
||||
if (!requestLine) {
|
||||
// Not a valid HTTP request line
|
||||
return null;
|
||||
}
|
||||
|
||||
// Initialize connection info
|
||||
const connectionInfo: IConnectionInfo = {
|
||||
protocol: 'http',
|
||||
method: requestLine.method,
|
||||
path: requestLine.path,
|
||||
httpVersion: requestLine.version
|
||||
};
|
||||
|
||||
// Check if we want to extract headers
|
||||
if (options?.extractFullHeaders !== false) {
|
||||
// Look for the end of headers (double CRLF)
|
||||
const headerEndSequence = Buffer.from('\r\n\r\n');
|
||||
const headerEndIndex = buffer.indexOf(headerEndSequence);
|
||||
|
||||
if (headerEndIndex === -1) {
|
||||
// Headers not complete yet
|
||||
const maxSize = options?.maxBufferSize || HttpDetector.MAX_HEADER_SIZE;
|
||||
if (buffer.length >= maxSize) {
|
||||
// Headers too large, reject
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
protocol: 'http',
|
||||
connectionInfo,
|
||||
isComplete: false,
|
||||
bytesNeeded: buffer.length + 200 // Estimate
|
||||
};
|
||||
}
|
||||
|
||||
// Extract all header lines
|
||||
const headerLines: string[] = [];
|
||||
let currentOffset = firstLineResult.nextOffset;
|
||||
|
||||
while (currentOffset < headerEndIndex) {
|
||||
const lineResult = extractLine(buffer, currentOffset);
|
||||
if (!lineResult) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (lineResult.line.length === 0) {
|
||||
// Empty line marks end of headers
|
||||
break;
|
||||
}
|
||||
|
||||
headerLines.push(lineResult.line);
|
||||
currentOffset = lineResult.nextOffset;
|
||||
}
|
||||
|
||||
// Parse headers
|
||||
const headers = parseHttpHeaders(headerLines);
|
||||
connectionInfo.headers = headers;
|
||||
|
||||
// Extract domain from Host header
|
||||
const hostHeader = headers['host'];
|
||||
if (hostHeader) {
|
||||
connectionInfo.domain = extractDomainFromHost(hostHeader);
|
||||
}
|
||||
|
||||
// Calculate remaining buffer
|
||||
const bodyStartIndex = headerEndIndex + 4; // After \r\n\r\n
|
||||
const remainingBuffer = buffer.length > bodyStartIndex
|
||||
? buffer.slice(bodyStartIndex)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
protocol: 'http',
|
||||
connectionInfo,
|
||||
remainingBuffer,
|
||||
isComplete: true
|
||||
};
|
||||
} else {
|
||||
// Just extract Host header for domain
|
||||
let currentOffset = firstLineResult.nextOffset;
|
||||
const maxLines = 50; // Reasonable limit
|
||||
|
||||
for (let i = 0; i < maxLines && currentOffset < buffer.length; i++) {
|
||||
const lineResult = extractLine(buffer, currentOffset);
|
||||
if (!lineResult) {
|
||||
// Need more data
|
||||
return {
|
||||
protocol: 'http',
|
||||
connectionInfo,
|
||||
isComplete: false,
|
||||
bytesNeeded: buffer.length + 50
|
||||
};
|
||||
}
|
||||
|
||||
if (lineResult.line.length === 0) {
|
||||
// End of headers
|
||||
break;
|
||||
}
|
||||
|
||||
// Quick check for Host header
|
||||
if (lineResult.line.toLowerCase().startsWith('host:')) {
|
||||
const colonIndex = lineResult.line.indexOf(':');
|
||||
const hostValue = lineResult.line.slice(colonIndex + 1).trim();
|
||||
connectionInfo.domain = extractDomainFromHost(hostValue);
|
||||
|
||||
// If we only needed the domain, we can return early
|
||||
return {
|
||||
protocol: 'http',
|
||||
connectionInfo,
|
||||
isComplete: true
|
||||
};
|
||||
}
|
||||
|
||||
currentOffset = lineResult.nextOffset;
|
||||
}
|
||||
|
||||
// If we reach here, no Host header found yet
|
||||
return {
|
||||
protocol: 'http',
|
||||
connectionInfo,
|
||||
isComplete: false,
|
||||
bytesNeeded: buffer.length + 100
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if buffer can be handled by this detector
|
||||
*/
|
||||
canHandle(buffer: Buffer): boolean {
|
||||
if (buffer.length < HttpDetector.MIN_HTTP_METHOD_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if first bytes could be an HTTP method
|
||||
const firstWord = buffer.slice(0, Math.min(10, buffer.length)).toString('ascii').split(' ')[0];
|
||||
return isHttpMethod(firstWord);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum bytes needed for detection
|
||||
*/
|
||||
getMinimumBytes(): number {
|
||||
return HttpDetector.MIN_HTTP_METHOD_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check if buffer starts with HTTP method
|
||||
*/
|
||||
static quickCheck(buffer: Buffer): boolean {
|
||||
if (buffer.length < 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check common HTTP methods
|
||||
const start = buffer.slice(0, 7).toString('ascii');
|
||||
return start.startsWith('GET ') ||
|
||||
start.startsWith('POST ') ||
|
||||
start.startsWith('PUT ') ||
|
||||
start.startsWith('DELETE ') ||
|
||||
start.startsWith('HEAD ') ||
|
||||
start.startsWith('OPTIONS') ||
|
||||
start.startsWith('PATCH ') ||
|
||||
start.startsWith('CONNECT') ||
|
||||
start.startsWith('TRACE ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle fragmented HTTP detection with connection tracking
|
||||
*/
|
||||
static detectWithFragments(
|
||||
buffer: Buffer,
|
||||
connectionId: string,
|
||||
options?: IDetectionOptions
|
||||
): IDetectionResult | null {
|
||||
const detector = new HttpDetector();
|
||||
|
||||
// Try direct detection first
|
||||
const directResult = detector.detect(buffer, options);
|
||||
if (directResult && directResult.isComplete) {
|
||||
// Clean up any tracked fragments for this connection
|
||||
this.fragmentedBuffers.delete(connectionId);
|
||||
return directResult;
|
||||
}
|
||||
|
||||
// Handle fragmentation
|
||||
let accumulator = this.fragmentedBuffers.get(connectionId);
|
||||
if (!accumulator) {
|
||||
accumulator = new BufferAccumulator();
|
||||
this.fragmentedBuffers.set(connectionId, accumulator);
|
||||
}
|
||||
|
||||
accumulator.append(buffer);
|
||||
const fullBuffer = accumulator.getBuffer();
|
||||
|
||||
// Check size limit
|
||||
const maxSize = options?.maxBufferSize || this.MAX_HEADER_SIZE;
|
||||
if (fullBuffer.length > maxSize) {
|
||||
// Too large, clean up and reject
|
||||
this.fragmentedBuffers.delete(connectionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try detection on accumulated buffer
|
||||
const result = detector.detect(fullBuffer, options);
|
||||
|
||||
if (result && result.isComplete) {
|
||||
// Success - clean up
|
||||
this.fragmentedBuffers.delete(connectionId);
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old fragment buffers
|
||||
*/
|
||||
static cleanupFragments(maxAge: number = 5000): void {
|
||||
// TODO: Add timestamp tracking to BufferAccumulator for cleanup
|
||||
// For now, just clear if too many connections
|
||||
if (this.fragmentedBuffers.size > 1000) {
|
||||
this.fragmentedBuffers.clear();
|
||||
}
|
||||
}
|
||||
}
|
257
ts/detection/detectors/tls-detector.ts
Normal file
257
ts/detection/detectors/tls-detector.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* TLS protocol detector
|
||||
*/
|
||||
|
||||
// TLS detector doesn't need plugins imports
|
||||
import type { IProtocolDetector } from '../models/interfaces.js';
|
||||
import type { IDetectionResult, IDetectionOptions, IConnectionInfo } from '../models/detection-types.js';
|
||||
import { readUInt16BE, readUInt24BE, BufferAccumulator } from '../utils/buffer-utils.js';
|
||||
import { tlsVersionToString } from '../utils/parser-utils.js';
|
||||
|
||||
// Import existing TLS utilities
|
||||
import { TlsUtils, TlsRecordType, TlsHandshakeType, TlsExtensionType } from '../../tls/utils/tls-utils.js';
|
||||
import { SniExtraction } from '../../tls/sni/sni-extraction.js';
|
||||
import { ClientHelloParser } from '../../tls/sni/client-hello-parser.js';
|
||||
|
||||
/**
|
||||
* TLS detector implementation
|
||||
*/
|
||||
export class TlsDetector implements IProtocolDetector {
|
||||
/**
|
||||
* Minimum bytes needed to identify TLS (record header)
|
||||
*/
|
||||
private static readonly MIN_TLS_HEADER_SIZE = 5;
|
||||
|
||||
/**
|
||||
* Fragment tracking for incomplete handshakes
|
||||
*/
|
||||
private static fragmentedBuffers = new Map<string, BufferAccumulator>();
|
||||
|
||||
/**
|
||||
* Detect TLS protocol from buffer
|
||||
*/
|
||||
detect(buffer: Buffer, options?: IDetectionOptions): IDetectionResult | null {
|
||||
// Check if buffer is too small
|
||||
if (buffer.length < TlsDetector.MIN_TLS_HEADER_SIZE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if this is a TLS record
|
||||
if (!this.isTlsRecord(buffer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract basic TLS info
|
||||
const recordType = buffer[0];
|
||||
const tlsMajor = buffer[1];
|
||||
const tlsMinor = buffer[2];
|
||||
const recordLength = readUInt16BE(buffer, 3);
|
||||
|
||||
// Initialize connection info
|
||||
const connectionInfo: IConnectionInfo = {
|
||||
protocol: 'tls',
|
||||
tlsVersion: tlsVersionToString(tlsMajor, tlsMinor) || undefined
|
||||
};
|
||||
|
||||
// If it's a handshake, try to extract more info
|
||||
if (recordType === TlsRecordType.HANDSHAKE && buffer.length >= 6) {
|
||||
const handshakeType = buffer[5];
|
||||
|
||||
// For ClientHello, extract SNI and other info
|
||||
if (handshakeType === TlsHandshakeType.CLIENT_HELLO) {
|
||||
// Check if we have the complete handshake
|
||||
const totalRecordLength = recordLength + 5; // Including TLS header
|
||||
if (buffer.length >= totalRecordLength) {
|
||||
// Extract SNI using existing logic
|
||||
const sni = SniExtraction.extractSNI(buffer);
|
||||
if (sni) {
|
||||
connectionInfo.domain = sni;
|
||||
connectionInfo.sni = sni;
|
||||
}
|
||||
|
||||
// Parse ClientHello for additional info
|
||||
const parseResult = ClientHelloParser.parseClientHello(buffer);
|
||||
if (parseResult.isValid) {
|
||||
// Extract ALPN if present
|
||||
const alpnExtension = parseResult.extensions.find(
|
||||
ext => ext.type === TlsExtensionType.APPLICATION_LAYER_PROTOCOL_NEGOTIATION
|
||||
);
|
||||
|
||||
if (alpnExtension) {
|
||||
connectionInfo.alpn = this.parseAlpnExtension(alpnExtension.data);
|
||||
}
|
||||
|
||||
// Store cipher suites if needed
|
||||
if (parseResult.cipherSuites && options?.extractFullHeaders) {
|
||||
connectionInfo.cipherSuites = this.parseCipherSuites(parseResult.cipherSuites);
|
||||
}
|
||||
}
|
||||
|
||||
// Return complete result
|
||||
return {
|
||||
protocol: 'tls',
|
||||
connectionInfo,
|
||||
remainingBuffer: buffer.length > totalRecordLength
|
||||
? buffer.slice(totalRecordLength)
|
||||
: undefined,
|
||||
isComplete: true
|
||||
};
|
||||
} else {
|
||||
// Incomplete handshake
|
||||
return {
|
||||
protocol: 'tls',
|
||||
connectionInfo,
|
||||
isComplete: false,
|
||||
bytesNeeded: totalRecordLength
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For other TLS record types, just return basic info
|
||||
return {
|
||||
protocol: 'tls',
|
||||
connectionInfo,
|
||||
isComplete: true,
|
||||
remainingBuffer: buffer.length > recordLength + 5
|
||||
? buffer.slice(recordLength + 5)
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if buffer can be handled by this detector
|
||||
*/
|
||||
canHandle(buffer: Buffer): boolean {
|
||||
return buffer.length >= TlsDetector.MIN_TLS_HEADER_SIZE &&
|
||||
this.isTlsRecord(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minimum bytes needed for detection
|
||||
*/
|
||||
getMinimumBytes(): number {
|
||||
return TlsDetector.MIN_TLS_HEADER_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if buffer contains a valid TLS record
|
||||
*/
|
||||
private isTlsRecord(buffer: Buffer): boolean {
|
||||
const recordType = buffer[0];
|
||||
|
||||
// Check for valid record type
|
||||
const validTypes = [
|
||||
TlsRecordType.CHANGE_CIPHER_SPEC,
|
||||
TlsRecordType.ALERT,
|
||||
TlsRecordType.HANDSHAKE,
|
||||
TlsRecordType.APPLICATION_DATA,
|
||||
TlsRecordType.HEARTBEAT
|
||||
];
|
||||
|
||||
if (!validTypes.includes(recordType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check TLS version bytes (should be 0x03 0x0X)
|
||||
if (buffer[1] !== 0x03) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check record length is reasonable
|
||||
const recordLength = readUInt16BE(buffer, 3);
|
||||
if (recordLength > 16384) { // Max TLS record size
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ALPN extension data
|
||||
*/
|
||||
private parseAlpnExtension(data: Buffer): string[] {
|
||||
const protocols: string[] = [];
|
||||
|
||||
if (data.length < 2) {
|
||||
return protocols;
|
||||
}
|
||||
|
||||
const listLength = readUInt16BE(data, 0);
|
||||
let offset = 2;
|
||||
|
||||
while (offset < Math.min(2 + listLength, data.length)) {
|
||||
const protoLength = data[offset];
|
||||
offset++;
|
||||
|
||||
if (offset + protoLength <= data.length) {
|
||||
const protocol = data.slice(offset, offset + protoLength).toString('ascii');
|
||||
protocols.push(protocol);
|
||||
offset += protoLength;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return protocols;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse cipher suites
|
||||
*/
|
||||
private parseCipherSuites(data: Buffer): number[] {
|
||||
const suites: number[] = [];
|
||||
|
||||
for (let i = 0; i + 1 < data.length; i += 2) {
|
||||
const suite = readUInt16BE(data, i);
|
||||
suites.push(suite);
|
||||
}
|
||||
|
||||
return suites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle fragmented TLS detection with connection tracking
|
||||
*/
|
||||
static detectWithFragments(
|
||||
buffer: Buffer,
|
||||
connectionId: string,
|
||||
options?: IDetectionOptions
|
||||
): IDetectionResult | null {
|
||||
const detector = new TlsDetector();
|
||||
|
||||
// Try direct detection first
|
||||
const directResult = detector.detect(buffer, options);
|
||||
if (directResult && directResult.isComplete) {
|
||||
// Clean up any tracked fragments for this connection
|
||||
this.fragmentedBuffers.delete(connectionId);
|
||||
return directResult;
|
||||
}
|
||||
|
||||
// Handle fragmentation
|
||||
let accumulator = this.fragmentedBuffers.get(connectionId);
|
||||
if (!accumulator) {
|
||||
accumulator = new BufferAccumulator();
|
||||
this.fragmentedBuffers.set(connectionId, accumulator);
|
||||
}
|
||||
|
||||
accumulator.append(buffer);
|
||||
const fullBuffer = accumulator.getBuffer();
|
||||
|
||||
// Try detection on accumulated buffer
|
||||
const result = detector.detect(fullBuffer, options);
|
||||
|
||||
if (result && result.isComplete) {
|
||||
// Success - clean up
|
||||
this.fragmentedBuffers.delete(connectionId);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check timeout
|
||||
if (options?.timeout) {
|
||||
// TODO: Implement timeout handling
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user