Implement PROXY protocol v1 support in SmartProxy

- Added ProxyProtocolParser class for parsing and generating PROXY protocol v1 headers.
- Integrated PROXY protocol parsing into RouteConnectionHandler for handling incoming connections from trusted proxies.
- Implemented WrappedSocket class to encapsulate real client information.
- Configured SmartProxy to accept and send PROXY protocol headers in routing actions.
- Developed comprehensive unit tests for PROXY protocol parsing and generation.
- Documented usage patterns, configuration, and best practices for proxy chaining scenarios.
- Added security and performance considerations for PROXY protocol implementation.
This commit is contained in:
2025-06-06 13:45:44 +00:00
parent 527cacb1a8
commit b3714d583d
12 changed files with 1521 additions and 32 deletions

View File

@@ -15,3 +15,4 @@ export * from './lifecycle-component.js';
export * from './binary-heap.js';
export * from './enhanced-connection-pool.js';
export * from './socket-utils.js';
export * from './proxy-protocol.js';

View File

@@ -0,0 +1,246 @@
import * as plugins from '../../plugins.js';
import { logger } from './logger.js';
/**
* Interface representing parsed PROXY protocol information
*/
export interface IProxyInfo {
protocol: 'TCP4' | 'TCP6' | 'UNKNOWN';
sourceIP: string;
sourcePort: number;
destinationIP: string;
destinationPort: number;
}
/**
* Interface for parse result including remaining data
*/
export interface IProxyParseResult {
proxyInfo: IProxyInfo | null;
remainingData: Buffer;
}
/**
* Parser for PROXY protocol v1 (text format)
* Spec: https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
*/
export class ProxyProtocolParser {
static readonly PROXY_V1_SIGNATURE = 'PROXY ';
static readonly MAX_HEADER_LENGTH = 107; // Max length for v1 header
static readonly HEADER_TERMINATOR = '\r\n';
/**
* Parse PROXY protocol v1 header from buffer
* Returns proxy info and remaining data after header
*/
static parse(data: Buffer): IProxyParseResult {
// Check if buffer starts with PROXY signature
if (!data.toString('ascii', 0, 6).startsWith(this.PROXY_V1_SIGNATURE)) {
return {
proxyInfo: null,
remainingData: data
};
}
// Find header terminator
const headerEndIndex = data.indexOf(this.HEADER_TERMINATOR);
if (headerEndIndex === -1) {
// Header incomplete, need more data
if (data.length > this.MAX_HEADER_LENGTH) {
// Header too long, invalid
throw new Error('PROXY protocol header exceeds maximum length');
}
return {
proxyInfo: null,
remainingData: data
};
}
// Extract header line
const headerLine = data.toString('ascii', 0, headerEndIndex);
const remainingData = data.slice(headerEndIndex + 2); // Skip \r\n
// Parse header
const parts = headerLine.split(' ');
if (parts.length < 2) {
throw new Error(`Invalid PROXY protocol header format: ${headerLine}`);
}
const [signature, protocol] = parts;
// Validate protocol
if (!['TCP4', 'TCP6', 'UNKNOWN'].includes(protocol)) {
throw new Error(`Invalid PROXY protocol: ${protocol}`);
}
// For UNKNOWN protocol, ignore addresses
if (protocol === 'UNKNOWN') {
return {
proxyInfo: {
protocol: 'UNKNOWN',
sourceIP: '',
sourcePort: 0,
destinationIP: '',
destinationPort: 0
},
remainingData
};
}
// For TCP4/TCP6, we need all 6 parts
if (parts.length !== 6) {
throw new Error(`Invalid PROXY protocol header format: ${headerLine}`);
}
const [, , srcIP, dstIP, srcPort, dstPort] = parts;
// Validate and parse ports
const sourcePort = parseInt(srcPort, 10);
const destinationPort = parseInt(dstPort, 10);
if (isNaN(sourcePort) || sourcePort < 0 || sourcePort > 65535) {
throw new Error(`Invalid source port: ${srcPort}`);
}
if (isNaN(destinationPort) || destinationPort < 0 || destinationPort > 65535) {
throw new Error(`Invalid destination port: ${dstPort}`);
}
// Validate IP addresses
const protocolType = protocol as 'TCP4' | 'TCP6' | 'UNKNOWN';
if (!this.isValidIP(srcIP, protocolType)) {
throw new Error(`Invalid source IP for ${protocol}: ${srcIP}`);
}
if (!this.isValidIP(dstIP, protocolType)) {
throw new Error(`Invalid destination IP for ${protocol}: ${dstIP}`);
}
return {
proxyInfo: {
protocol: protocol as 'TCP4' | 'TCP6',
sourceIP: srcIP,
sourcePort,
destinationIP: dstIP,
destinationPort
},
remainingData
};
}
/**
* Generate PROXY protocol v1 header
*/
static generate(info: IProxyInfo): Buffer {
if (info.protocol === 'UNKNOWN') {
return Buffer.from(`PROXY UNKNOWN\r\n`, 'ascii');
}
const header = `PROXY ${info.protocol} ${info.sourceIP} ${info.destinationIP} ${info.sourcePort} ${info.destinationPort}\r\n`;
if (header.length > this.MAX_HEADER_LENGTH) {
throw new Error('Generated PROXY protocol header exceeds maximum length');
}
return Buffer.from(header, 'ascii');
}
/**
* Validate IP address format
*/
private static isValidIP(ip: string, protocol: 'TCP4' | 'TCP6' | 'UNKNOWN'): boolean {
if (protocol === 'TCP4') {
return plugins.net.isIPv4(ip);
} else if (protocol === 'TCP6') {
return plugins.net.isIPv6(ip);
}
return false;
}
/**
* Attempt to read a complete PROXY protocol header from a socket
* Returns null if no PROXY protocol detected or incomplete
*/
static async readFromSocket(socket: plugins.net.Socket, timeout: number = 5000): Promise<IProxyParseResult | null> {
return new Promise((resolve) => {
let buffer = Buffer.alloc(0);
let resolved = false;
const cleanup = () => {
socket.removeListener('data', onData);
socket.removeListener('error', onError);
clearTimeout(timer);
};
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
cleanup();
resolve({
proxyInfo: null,
remainingData: buffer
});
}
}, timeout);
const onData = (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
// Check if we have enough data
if (!buffer.toString('ascii', 0, Math.min(6, buffer.length)).startsWith(this.PROXY_V1_SIGNATURE)) {
// Not PROXY protocol
resolved = true;
cleanup();
resolve({
proxyInfo: null,
remainingData: buffer
});
return;
}
// Try to parse
try {
const result = this.parse(buffer);
if (result.proxyInfo) {
// Successfully parsed
resolved = true;
cleanup();
resolve(result);
} else if (buffer.length > this.MAX_HEADER_LENGTH) {
// Header too long
resolved = true;
cleanup();
resolve({
proxyInfo: null,
remainingData: buffer
});
}
// Otherwise continue reading
} catch (error) {
// Parse error
logger.log('error', `PROXY protocol parse error: ${error.message}`);
resolved = true;
cleanup();
resolve({
proxyInfo: null,
remainingData: buffer
});
}
};
const onError = (error: Error) => {
logger.log('error', `Socket error while reading PROXY protocol: ${error.message}`);
resolved = true;
cleanup();
resolve({
proxyInfo: null,
remainingData: buffer
});
};
socket.on('data', onData);
socket.on('error', onError);
});
}
}

View File

@@ -70,6 +70,7 @@ export class ConnectionManager extends LifecycleComponent {
const connectionId = this.generateConnectionId();
const remoteIP = socket.remoteAddress || '';
const remotePort = socket.remotePort || 0;
const localPort = socket.localPort || 0;
const now = Date.now();
@@ -85,6 +86,7 @@ export class ConnectionManager extends LifecycleComponent {
bytesReceived: 0,
bytesSent: 0,
remoteIP,
remotePort,
localPort,
isTLS: false,
tlsHandshakeComplete: false,

View File

@@ -151,6 +151,7 @@ export interface IConnectionRecord {
bytesReceived: number; // Total bytes received
bytesSent: number; // Total bytes sent
remoteIP: string; // Remote IP (cached for logging after socket close)
remotePort: number; // Remote port (cached for logging after socket close)
localPort: number; // Local port (cached for logging)
isTLS: boolean; // Whether this connection is a TLS connection
tlsHandshakeComplete: boolean; // Whether the TLS handshake is complete

View File

@@ -250,6 +250,9 @@ export interface IRouteAction {
// Socket handler function (when type is 'socket-handler')
socketHandler?: TSocketHandler;
// PROXY protocol support
sendProxyProtocol?: boolean;
}
/**

View File

@@ -13,6 +13,7 @@ import { SharedRouteManager as RouteManager } from '../../core/routing/route-man
import { cleanupSocket, createIndependentSocketHandlers, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js';
import { WrappedSocket } from '../../core/models/wrapped-socket.js';
import { getUnderlyingSocket } from '../../core/models/socket-types.js';
import { ProxyProtocolParser } from '../../core/utils/proxy-protocol.js';
/**
* Handles new connection processing and setup logic with support for route-based configuration
@@ -295,17 +296,8 @@ export class RouteConnectionHandler {
}
});
// First data handler to capture initial TLS handshake
socket.once('data', (chunk: Buffer) => {
// Clear the initial timeout since we've received data
if (initialTimeout) {
clearTimeout(initialTimeout);
initialTimeout = null;
}
initialDataReceived = true;
record.hasReceivedInitialData = true;
// Handler for processing initial data (after potential PROXY protocol)
const processInitialData = (chunk: Buffer) => {
// Block non-TLS connections on port 443
if (!this.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.`, {
@@ -381,6 +373,67 @@ export class RouteConnectionHandler {
// Find the appropriate route for this connection
this.routeConnection(socket, record, serverName, chunk);
};
// First data handler to capture initial TLS handshake or PROXY protocol
socket.once('data', async (chunk: Buffer) => {
// Clear the initial timeout since we've received data
if (initialTimeout) {
clearTimeout(initialTimeout);
initialTimeout = null;
}
initialDataReceived = true;
record.hasReceivedInitialData = true;
// Check if this is from a trusted proxy and might have PROXY protocol
if (this.settings.proxyIPs?.includes(socket.remoteAddress || '') && this.settings.acceptProxyProtocol !== false) {
// Check if this starts with PROXY protocol
if (chunk.toString('ascii', 0, Math.min(6, chunk.length)).startsWith('PROXY ')) {
try {
const parseResult = ProxyProtocolParser.parse(chunk);
if (parseResult.proxyInfo) {
// Update the wrapped socket with real client info (if it's a WrappedSocket)
if (socket instanceof WrappedSocket) {
socket.setProxyInfo(parseResult.proxyInfo.sourceIP, parseResult.proxyInfo.sourcePort);
}
// Update connection record with real client info
record.remoteIP = parseResult.proxyInfo.sourceIP;
record.remotePort = parseResult.proxyInfo.sourcePort;
logger.log('info', `PROXY protocol parsed successfully`, {
connectionId,
realClientIP: parseResult.proxyInfo.sourceIP,
realClientPort: parseResult.proxyInfo.sourcePort,
proxyIP: socket.remoteAddress,
component: 'route-handler'
});
// Process remaining data if any
if (parseResult.remainingData.length > 0) {
processInitialData(parseResult.remainingData);
} else {
// Wait for more data
socket.once('data', processInitialData);
}
return;
}
} catch (error) {
logger.log('error', `Failed to parse PROXY protocol from trusted proxy`, {
connectionId,
error: error.message,
proxyIP: socket.remoteAddress,
component: 'route-handler'
});
// Continue processing as normal data
}
}
}
// Process as normal data (no PROXY protocol)
processInitialData(chunk);
});
}
@@ -1119,7 +1172,7 @@ export class RouteConnectionHandler {
// Clean up the connection record - this is critical!
this.connectionManager.cleanupConnection(record, `connection_failed_${(error as any).code || 'unknown'}`);
},
onConnect: () => {
onConnect: async () => {
if (this.settings.enableDetailedLogging) {
logger.log('info', `Connection ${connectionId} established to target ${finalTargetHost}:${finalTargetPort}`, {
connectionId,
@@ -1135,6 +1188,56 @@ export class RouteConnectionHandler {
// Add the normal error handler for established connections
targetSocket.on('error', this.connectionManager.handleError('outgoing', record));
// Check if we should send PROXY protocol header
const shouldSendProxyProtocol = record.routeConfig?.action?.sendProxyProtocol ||
this.settings.sendProxyProtocol;
if (shouldSendProxyProtocol) {
try {
// Generate PROXY protocol header
const proxyInfo = {
protocol: (record.remoteIP.includes(':') ? 'TCP6' : 'TCP4') as 'TCP4' | 'TCP6',
sourceIP: record.remoteIP,
sourcePort: record.remotePort || socket.remotePort || 0,
destinationIP: socket.localAddress || '',
destinationPort: socket.localPort || 0
};
const proxyHeader = ProxyProtocolParser.generate(proxyInfo);
// Send PROXY protocol header first
await new Promise<void>((resolve, reject) => {
targetSocket.write(proxyHeader, (err) => {
if (err) {
logger.log('error', `Failed to send PROXY protocol header`, {
connectionId,
error: err.message,
component: 'route-handler'
});
reject(err);
} else {
logger.log('info', `PROXY protocol header sent to backend`, {
connectionId,
targetHost: finalTargetHost,
targetPort: finalTargetPort,
sourceIP: proxyInfo.sourceIP,
sourcePort: proxyInfo.sourcePort,
component: 'route-handler'
});
resolve();
}
});
});
} catch (error) {
logger.log('error', `Error sending PROXY protocol header`, {
connectionId,
error: error.message,
component: 'route-handler'
});
// Continue anyway - don't break the connection
}
}
// Flush any pending data to target
if (record.pendingData.length > 0) {
const combinedData = Buffer.concat(record.pendingData);