- 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.
281 lines
8.1 KiB
TypeScript
281 lines
8.1 KiB
TypeScript
/**
|
|
* 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();
|
|
}
|
|
}
|
|
} |