This commit addresses critical issues where unhandled socket connection errors (ECONNREFUSED) would crash the server and cause memory leaks with rising connection counts. Changes: - Add createSocketWithErrorHandler() utility that attaches error handlers immediately upon socket creation - Update https-passthrough-handler to use safe socket creation and clean up client sockets on server connection failure - Update https-terminate-to-http-handler to use safe socket creation - Ensure proper connection cleanup when server connections fail - Document the fix in readme.hints.md and create implementation plan in readme.plan.md The fix prevents race conditions where sockets could emit errors before handlers were attached, and ensures failed connections are properly cleaned up to prevent memory leaks.
298 lines
9.7 KiB
TypeScript
298 lines
9.7 KiB
TypeScript
import * as plugins from '../../plugins.js';
|
|
import { ForwardingHandler } from './base-handler.js';
|
|
import type { IForwardConfig } from '../config/forwarding-types.js';
|
|
import { ForwardingHandlerEvents } from '../config/forwarding-types.js';
|
|
import { createSocketCleanupHandler, setupSocketHandlers, createSocketWithErrorHandler } from '../../core/utils/socket-utils.js';
|
|
|
|
/**
|
|
* Handler for HTTPS termination with HTTP backend
|
|
*/
|
|
export class HttpsTerminateToHttpHandler extends ForwardingHandler {
|
|
private tlsServer: plugins.tls.Server | null = null;
|
|
private secureContext: plugins.tls.SecureContext | null = null;
|
|
|
|
/**
|
|
* Create a new HTTPS termination with HTTP backend handler
|
|
* @param config The forwarding configuration
|
|
*/
|
|
constructor(config: IForwardConfig) {
|
|
super(config);
|
|
|
|
// Validate that this is an HTTPS terminate to HTTP configuration
|
|
if (config.type !== 'https-terminate-to-http') {
|
|
throw new Error(`Invalid configuration type for HttpsTerminateToHttpHandler: ${config.type}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize the handler, setting up TLS context
|
|
*/
|
|
public async initialize(): Promise<void> {
|
|
// We need to load or create TLS certificates
|
|
if (this.config.https?.customCert) {
|
|
// Use custom certificate from configuration
|
|
this.secureContext = plugins.tls.createSecureContext({
|
|
key: this.config.https.customCert.key,
|
|
cert: this.config.https.customCert.cert
|
|
});
|
|
|
|
this.emit(ForwardingHandlerEvents.CERTIFICATE_LOADED, {
|
|
source: 'config',
|
|
domain: this.config.target.host
|
|
});
|
|
} else if (this.config.acme?.enabled) {
|
|
// Request certificate through ACME if needed
|
|
this.emit(ForwardingHandlerEvents.CERTIFICATE_NEEDED, {
|
|
domain: Array.isArray(this.config.target.host)
|
|
? this.config.target.host[0]
|
|
: this.config.target.host,
|
|
useProduction: this.config.acme.production || false
|
|
});
|
|
|
|
// In a real implementation, we would wait for the certificate to be issued
|
|
// For now, we'll use a dummy context
|
|
this.secureContext = plugins.tls.createSecureContext({
|
|
key: '-----BEGIN PRIVATE KEY-----\nDummy key\n-----END PRIVATE KEY-----',
|
|
cert: '-----BEGIN CERTIFICATE-----\nDummy cert\n-----END CERTIFICATE-----'
|
|
});
|
|
} else {
|
|
throw new Error('HTTPS termination requires either a custom certificate or ACME enabled');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the secure context for TLS termination
|
|
* Called when a certificate is available
|
|
* @param context The secure context
|
|
*/
|
|
public setSecureContext(context: plugins.tls.SecureContext): void {
|
|
this.secureContext = context;
|
|
}
|
|
|
|
/**
|
|
* Handle a TLS/SSL socket connection by terminating TLS and forwarding to HTTP backend
|
|
* @param clientSocket The incoming socket from the client
|
|
*/
|
|
public handleConnection(clientSocket: plugins.net.Socket): void {
|
|
// Make sure we have a secure context
|
|
if (!this.secureContext) {
|
|
clientSocket.destroy(new Error('TLS secure context not initialized'));
|
|
return;
|
|
}
|
|
|
|
const remoteAddress = clientSocket.remoteAddress || 'unknown';
|
|
const remotePort = clientSocket.remotePort || 0;
|
|
|
|
// Create a TLS socket using our secure context
|
|
const tlsSocket = new plugins.tls.TLSSocket(clientSocket, {
|
|
secureContext: this.secureContext,
|
|
isServer: true,
|
|
server: this.tlsServer || undefined
|
|
});
|
|
|
|
this.emit(ForwardingHandlerEvents.CONNECTED, {
|
|
remoteAddress,
|
|
remotePort,
|
|
tls: true
|
|
});
|
|
|
|
// Variables to track connections
|
|
let backendSocket: plugins.net.Socket | null = null;
|
|
let dataBuffer = Buffer.alloc(0);
|
|
let connectionEstablished = false;
|
|
|
|
// Create cleanup handler for all sockets
|
|
const handleClose = createSocketCleanupHandler(tlsSocket, backendSocket, (reason) => {
|
|
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
|
remoteAddress,
|
|
reason
|
|
});
|
|
dataBuffer = Buffer.alloc(0);
|
|
connectionEstablished = false;
|
|
});
|
|
|
|
// Set up error handling with our cleanup utility
|
|
setupSocketHandlers(tlsSocket, handleClose, undefined, 'tls');
|
|
|
|
// Set timeout
|
|
const timeout = this.getTimeout();
|
|
tlsSocket.setTimeout(timeout);
|
|
|
|
tlsSocket.on('timeout', () => {
|
|
this.emit(ForwardingHandlerEvents.ERROR, {
|
|
remoteAddress,
|
|
error: 'TLS connection timeout'
|
|
});
|
|
handleClose('timeout');
|
|
});
|
|
|
|
// Handle TLS data
|
|
tlsSocket.on('data', (data) => {
|
|
// If backend connection already established, just forward the data
|
|
if (connectionEstablished && backendSocket && !backendSocket.destroyed) {
|
|
backendSocket.write(data);
|
|
return;
|
|
}
|
|
|
|
// Append to buffer
|
|
dataBuffer = Buffer.concat([dataBuffer, data]);
|
|
|
|
// Very basic HTTP parsing - in a real implementation, use http-parser
|
|
if (dataBuffer.includes(Buffer.from('\r\n\r\n')) && !connectionEstablished) {
|
|
const target = this.getTargetFromConfig();
|
|
|
|
// Create backend connection with immediate error handling
|
|
backendSocket = createSocketWithErrorHandler({
|
|
port: target.port,
|
|
host: target.host,
|
|
onError: (error) => {
|
|
this.emit(ForwardingHandlerEvents.ERROR, {
|
|
error: error.message,
|
|
code: (error as any).code || 'UNKNOWN',
|
|
remoteAddress,
|
|
target: `${target.host}:${target.port}`
|
|
});
|
|
|
|
// Clean up the TLS socket since we can't forward
|
|
if (!tlsSocket.destroyed) {
|
|
tlsSocket.destroy();
|
|
}
|
|
|
|
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
|
remoteAddress,
|
|
reason: `backend_connection_failed: ${error.message}`
|
|
});
|
|
},
|
|
onConnect: () => {
|
|
connectionEstablished = true;
|
|
|
|
// Send buffered data
|
|
if (dataBuffer.length > 0) {
|
|
backendSocket!.write(dataBuffer);
|
|
dataBuffer = Buffer.alloc(0);
|
|
}
|
|
|
|
// Set up bidirectional data flow
|
|
tlsSocket.pipe(backendSocket!);
|
|
backendSocket!.pipe(tlsSocket);
|
|
}
|
|
});
|
|
|
|
// Update the cleanup handler with the backend socket
|
|
const newHandleClose = createSocketCleanupHandler(tlsSocket, backendSocket, (reason) => {
|
|
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
|
|
remoteAddress,
|
|
reason
|
|
});
|
|
dataBuffer = Buffer.alloc(0);
|
|
connectionEstablished = false;
|
|
});
|
|
|
|
// Set up handlers for backend socket
|
|
setupSocketHandlers(backendSocket, newHandleClose, undefined, 'backend');
|
|
|
|
backendSocket.on('error', (error) => {
|
|
this.emit(ForwardingHandlerEvents.ERROR, {
|
|
remoteAddress,
|
|
error: `Target connection error: ${error.message}`
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle an HTTP request by forwarding to the HTTP backend
|
|
* @param req The HTTP request
|
|
* @param res The HTTP response
|
|
*/
|
|
public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
|
// Check if we should redirect to HTTPS
|
|
if (this.config.http?.redirectToHttps) {
|
|
this.redirectToHttps(req, res);
|
|
return;
|
|
}
|
|
|
|
// Get the target from configuration
|
|
const target = this.getTargetFromConfig();
|
|
|
|
// Create custom headers with variable substitution
|
|
const variables = {
|
|
clientIp: req.socket.remoteAddress || 'unknown'
|
|
};
|
|
|
|
// Prepare headers, merging with any custom headers from config
|
|
const headers = this.applyCustomHeaders(req.headers, variables);
|
|
|
|
// Create the proxy request options
|
|
const options = {
|
|
hostname: target.host,
|
|
port: target.port,
|
|
path: req.url,
|
|
method: req.method,
|
|
headers
|
|
};
|
|
|
|
// Create the proxy request
|
|
const proxyReq = plugins.http.request(options, (proxyRes) => {
|
|
// Copy status code and headers from the proxied response
|
|
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
|
|
|
|
// Pipe the proxy response to the client response
|
|
proxyRes.pipe(res);
|
|
|
|
// Track response size for logging
|
|
let responseSize = 0;
|
|
proxyRes.on('data', (chunk) => {
|
|
responseSize += chunk.length;
|
|
});
|
|
|
|
proxyRes.on('end', () => {
|
|
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
|
|
statusCode: proxyRes.statusCode,
|
|
headers: proxyRes.headers,
|
|
size: responseSize
|
|
});
|
|
});
|
|
});
|
|
|
|
// Handle errors in the proxy request
|
|
proxyReq.on('error', (error) => {
|
|
this.emit(ForwardingHandlerEvents.ERROR, {
|
|
remoteAddress: req.socket.remoteAddress,
|
|
error: `Proxy request error: ${error.message}`
|
|
});
|
|
|
|
// Send an error response if headers haven't been sent yet
|
|
if (!res.headersSent) {
|
|
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
res.end(`Error forwarding request: ${error.message}`);
|
|
} else {
|
|
// Just end the response if headers have already been sent
|
|
res.end();
|
|
}
|
|
});
|
|
|
|
// Track request details for logging
|
|
let requestSize = 0;
|
|
req.on('data', (chunk) => {
|
|
requestSize += chunk.length;
|
|
});
|
|
|
|
// Log the request
|
|
this.emit(ForwardingHandlerEvents.HTTP_REQUEST, {
|
|
method: req.method,
|
|
url: req.url,
|
|
headers: req.headers,
|
|
remoteAddress: req.socket.remoteAddress,
|
|
target: `${target.host}:${target.port}`
|
|
});
|
|
|
|
// Pipe the client request to the proxy request
|
|
if (req.readable) {
|
|
req.pipe(proxyReq);
|
|
} else {
|
|
proxyReq.end();
|
|
}
|
|
}
|
|
} |