@push.rocks/smartproxy
Version:
A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.
1,117 lines • 123 kB
JavaScript
import * as plugins from './plugins.js';
import { ProxyRouter } from './classes.router.js';
import { Port80Handler, Port80HandlerEvents } from './classes.port80handler.js';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
export class NetworkProxy {
/**
* Creates a new NetworkProxy instance
*/
constructor(optionsArg) {
this.proxyConfigs = [];
this.defaultHeaders = {};
// State tracking
this.router = new ProxyRouter();
this.socketMap = new plugins.lik.ObjectMap();
this.activeContexts = new Set();
this.connectedClients = 0;
this.startTime = 0;
this.requestsServed = 0;
this.failedRequests = 0;
// New tracking for PortProxy integration
this.portProxyConnections = 0;
this.tlsTerminatedConnections = 0;
this.certificateCache = new Map();
// Port80Handler for certificate management
this.port80Handler = null;
// New connection pool for backend connections
this.connectionPool = new Map();
// Track round-robin positions for load balancing
this.roundRobinPositions = new Map();
// Set default options
this.options = {
port: optionsArg.port,
maxConnections: optionsArg.maxConnections || 10000,
keepAliveTimeout: optionsArg.keepAliveTimeout || 120000, // 2 minutes
headersTimeout: optionsArg.headersTimeout || 60000, // 1 minute
logLevel: optionsArg.logLevel || 'info',
cors: optionsArg.cors || {
allowOrigin: '*',
allowMethods: 'GET, POST, PUT, DELETE, OPTIONS',
allowHeaders: 'Content-Type, Authorization',
maxAge: 86400
},
// New defaults for PortProxy integration
connectionPoolSize: optionsArg.connectionPoolSize || 50,
portProxyIntegration: optionsArg.portProxyIntegration || false,
// Default ACME options
acme: {
enabled: optionsArg.acme?.enabled || false,
port: optionsArg.acme?.port || 80,
contactEmail: optionsArg.acme?.contactEmail || 'admin@example.com',
useProduction: optionsArg.acme?.useProduction || false, // Default to staging for safety
renewThresholdDays: optionsArg.acme?.renewThresholdDays || 30,
autoRenew: optionsArg.acme?.autoRenew !== false, // Default to true
certificateStore: optionsArg.acme?.certificateStore || './certs',
skipConfiguredCerts: optionsArg.acme?.skipConfiguredCerts || false
}
};
// Set up certificate store directory
this.certificateStoreDir = path.resolve(this.options.acme.certificateStore);
// Ensure certificate store directory exists
try {
if (!fs.existsSync(this.certificateStoreDir)) {
fs.mkdirSync(this.certificateStoreDir, { recursive: true });
this.log('info', `Created certificate store directory: ${this.certificateStoreDir}`);
}
}
catch (error) {
this.log('warn', `Failed to create certificate store directory: ${error}`);
}
this.loadDefaultCertificates();
}
/**
* Loads default certificates from the filesystem
*/
loadDefaultCertificates() {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const certPath = path.join(__dirname, '..', 'assets', 'certs');
try {
this.defaultCertificates = {
key: fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'),
cert: fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8')
};
this.log('info', 'Default certificates loaded successfully');
}
catch (error) {
this.log('error', 'Error loading default certificates', error);
// Generate self-signed fallback certificates
try {
// This is a placeholder for actual certificate generation code
// In a real implementation, you would use a library like selfsigned to generate certs
this.defaultCertificates = {
key: "FALLBACK_KEY_CONTENT",
cert: "FALLBACK_CERT_CONTENT"
};
this.log('warn', 'Using fallback self-signed certificates');
}
catch (fallbackError) {
this.log('error', 'Failed to generate fallback certificates', fallbackError);
throw new Error('Could not load or generate SSL certificates');
}
}
}
/**
* Returns the port number this NetworkProxy is listening on
* Useful for PortProxy to determine where to forward connections
*/
getListeningPort() {
return this.options.port;
}
/**
* Updates the server capacity settings
* @param maxConnections Maximum number of simultaneous connections
* @param keepAliveTimeout Keep-alive timeout in milliseconds
* @param connectionPoolSize Size of the connection pool per backend
*/
updateCapacity(maxConnections, keepAliveTimeout, connectionPoolSize) {
if (maxConnections !== undefined) {
this.options.maxConnections = maxConnections;
this.log('info', `Updated max connections to ${maxConnections}`);
}
if (keepAliveTimeout !== undefined) {
this.options.keepAliveTimeout = keepAliveTimeout;
if (this.httpsServer) {
this.httpsServer.keepAliveTimeout = keepAliveTimeout;
this.log('info', `Updated keep-alive timeout to ${keepAliveTimeout}ms`);
}
}
if (connectionPoolSize !== undefined) {
this.options.connectionPoolSize = connectionPoolSize;
this.log('info', `Updated connection pool size to ${connectionPoolSize}`);
// Cleanup excess connections in the pool if the size was reduced
this.cleanupConnectionPool();
}
}
/**
* Returns current server metrics
* Useful for PortProxy to determine which NetworkProxy to use for load balancing
*/
getMetrics() {
return {
activeConnections: this.connectedClients,
totalRequests: this.requestsServed,
failedRequests: this.failedRequests,
portProxyConnections: this.portProxyConnections,
tlsTerminatedConnections: this.tlsTerminatedConnections,
connectionPoolSize: Array.from(this.connectionPool.entries()).reduce((acc, [host, connections]) => {
acc[host] = connections.length;
return acc;
}, {}),
uptime: Math.floor((Date.now() - this.startTime) / 1000),
memoryUsage: process.memoryUsage(),
activeWebSockets: this.wsServer?.clients.size || 0
};
}
/**
* Cleanup the connection pool by removing idle connections
* or reducing pool size if it exceeds the configured maximum
*/
cleanupConnectionPool() {
const now = Date.now();
const idleTimeout = this.options.keepAliveTimeout || 120000; // 2 minutes default
for (const [host, connections] of this.connectionPool.entries()) {
// Sort by last used time (oldest first)
connections.sort((a, b) => a.lastUsed - b.lastUsed);
// Remove idle connections older than the idle timeout
let removed = 0;
while (connections.length > 0) {
const connection = connections[0];
// Remove if idle and exceeds timeout, or if pool is too large
if ((connection.isIdle && now - connection.lastUsed > idleTimeout) ||
connections.length > this.options.connectionPoolSize) {
try {
if (!connection.socket.destroyed) {
connection.socket.end();
connection.socket.destroy();
}
}
catch (err) {
this.log('error', `Error destroying pooled connection to ${host}`, err);
}
connections.shift(); // Remove from pool
removed++;
}
else {
break; // Stop removing if we've reached active or recent connections
}
}
if (removed > 0) {
this.log('debug', `Removed ${removed} idle connections from pool for ${host}, ${connections.length} remaining`);
}
// Update the pool with the remaining connections
if (connections.length === 0) {
this.connectionPool.delete(host);
}
else {
this.connectionPool.set(host, connections);
}
}
}
/**
* Get a connection from the pool or create a new one
*/
getConnectionFromPool(host, port) {
return new Promise((resolve, reject) => {
const poolKey = `${host}:${port}`;
const connectionList = this.connectionPool.get(poolKey) || [];
// Look for an idle connection
const idleConnectionIndex = connectionList.findIndex(c => c.isIdle);
if (idleConnectionIndex >= 0) {
// Get existing connection from pool
const connection = connectionList[idleConnectionIndex];
connection.isIdle = false;
connection.lastUsed = Date.now();
this.log('debug', `Reusing connection from pool for ${poolKey}`);
// Update the pool
this.connectionPool.set(poolKey, connectionList);
resolve(connection.socket);
return;
}
// No idle connection available, create a new one if pool isn't full
if (connectionList.length < this.options.connectionPoolSize) {
this.log('debug', `Creating new connection to ${host}:${port}`);
try {
const socket = plugins.net.connect({
host,
port,
keepAlive: true,
keepAliveInitialDelay: 30000 // 30 seconds
});
socket.once('connect', () => {
// Add to connection pool
const connection = {
socket,
lastUsed: Date.now(),
isIdle: false
};
connectionList.push(connection);
this.connectionPool.set(poolKey, connectionList);
// Setup cleanup when the connection is closed
socket.once('close', () => {
const idx = connectionList.findIndex(c => c.socket === socket);
if (idx >= 0) {
connectionList.splice(idx, 1);
this.connectionPool.set(poolKey, connectionList);
this.log('debug', `Removed closed connection from pool for ${poolKey}`);
}
});
resolve(socket);
});
socket.once('error', (err) => {
this.log('error', `Error creating connection to ${host}:${port}`, err);
reject(err);
});
}
catch (err) {
this.log('error', `Failed to create connection to ${host}:${port}`, err);
reject(err);
}
}
else {
// Pool is full, wait for an idle connection or reject
this.log('warn', `Connection pool for ${poolKey} is full (${connectionList.length})`);
reject(new Error(`Connection pool for ${poolKey} is full`));
}
});
}
/**
* Return a connection to the pool for reuse
*/
returnConnectionToPool(socket, host, port) {
const poolKey = `${host}:${port}`;
const connectionList = this.connectionPool.get(poolKey) || [];
// Find this connection in the pool
const connectionIndex = connectionList.findIndex(c => c.socket === socket);
if (connectionIndex >= 0) {
// Mark as idle and update last used time
connectionList[connectionIndex].isIdle = true;
connectionList[connectionIndex].lastUsed = Date.now();
this.log('debug', `Returned connection to pool for ${poolKey}`);
}
else {
this.log('warn', `Attempted to return unknown connection to pool for ${poolKey}`);
}
}
/**
* Initializes the Port80Handler for ACME certificate management
* @private
*/
async initializePort80Handler() {
if (!this.options.acme.enabled) {
return;
}
// Create certificate manager
this.port80Handler = new Port80Handler({
port: this.options.acme.port,
contactEmail: this.options.acme.contactEmail,
useProduction: this.options.acme.useProduction,
renewThresholdDays: this.options.acme.renewThresholdDays,
httpsRedirectPort: this.options.port, // Redirect to our HTTPS port
renewCheckIntervalHours: 24 // Check daily for renewals
});
// Register event handlers
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, this.handleCertificateIssued.bind(this));
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, this.handleCertificateIssued.bind(this));
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, this.handleCertificateFailed.bind(this));
this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, (data) => {
this.log('info', `Certificate for ${data.domain} expires in ${data.daysRemaining} days`);
});
// Start the handler
try {
await this.port80Handler.start();
this.log('info', `Port80Handler started on port ${this.options.acme.port}`);
// Add domains from proxy configs
this.registerDomainsWithPort80Handler();
}
catch (error) {
this.log('error', `Failed to start Port80Handler: ${error}`);
this.port80Handler = null;
}
}
/**
* Registers domains from proxy configs with the Port80Handler
* @private
*/
registerDomainsWithPort80Handler() {
if (!this.port80Handler)
return;
// Get all hostnames from proxy configs
this.proxyConfigs.forEach(config => {
const hostname = config.hostName;
// Skip wildcard domains - can't get certs for these with HTTP-01 validation
if (hostname.includes('*')) {
this.log('info', `Skipping wildcard domain for ACME: ${hostname}`);
return;
}
// Skip domains already with certificates if configured to do so
if (this.options.acme.skipConfiguredCerts) {
const cachedCert = this.certificateCache.get(hostname);
if (cachedCert) {
this.log('info', `Skipping domain with existing certificate: ${hostname}`);
return;
}
}
// Check for existing certificate in the store
const certPath = path.join(this.certificateStoreDir, `${hostname}.cert.pem`);
const keyPath = path.join(this.certificateStoreDir, `${hostname}.key.pem`);
try {
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
// Load existing certificate and key
const cert = fs.readFileSync(certPath, 'utf8');
const key = fs.readFileSync(keyPath, 'utf8');
// Extract expiry date from certificate if possible
let expiryDate;
try {
const matches = cert.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
if (matches && matches[1]) {
expiryDate = new Date(matches[1]);
}
}
catch (error) {
this.log('warn', `Failed to extract expiry date from certificate for ${hostname}`);
}
// Update the certificate in the handler
this.port80Handler.setCertificate(hostname, cert, key, expiryDate);
// Also update our own certificate cache
this.updateCertificateCache(hostname, cert, key, expiryDate);
this.log('info', `Loaded existing certificate for ${hostname}`);
}
else {
// Register the domain for certificate issuance with new domain options format
const domainOptions = {
domainName: hostname,
sslRedirect: true,
acmeMaintenance: true
};
this.port80Handler.addDomain(domainOptions);
this.log('info', `Registered domain for ACME certificate issuance: ${hostname}`);
}
}
catch (error) {
this.log('error', `Error registering domain ${hostname} with Port80Handler: ${error}`);
}
});
}
/**
* Handles newly issued or renewed certificates from Port80Handler
* @private
*/
handleCertificateIssued(data) {
const { domain, certificate, privateKey, expiryDate } = data;
this.log('info', `Certificate ${this.certificateCache.has(domain) ? 'renewed' : 'issued'} for ${domain}, valid until ${expiryDate.toISOString()}`);
// Update certificate in HTTPS server
this.updateCertificateCache(domain, certificate, privateKey, expiryDate);
// Save the certificate to the filesystem
this.saveCertificateToStore(domain, certificate, privateKey);
}
/**
* Handles certificate issuance failures
* @private
*/
handleCertificateFailed(data) {
this.log('error', `Certificate issuance failed for ${data.domain}: ${data.error}`);
}
/**
* Saves certificate and private key to the filesystem
* @private
*/
saveCertificateToStore(domain, certificate, privateKey) {
try {
const certPath = path.join(this.certificateStoreDir, `${domain}.cert.pem`);
const keyPath = path.join(this.certificateStoreDir, `${domain}.key.pem`);
fs.writeFileSync(certPath, certificate);
fs.writeFileSync(keyPath, privateKey);
// Ensure private key has restricted permissions
try {
fs.chmodSync(keyPath, 0o600);
}
catch (error) {
this.log('warn', `Failed to set permissions on private key for ${domain}: ${error}`);
}
this.log('info', `Saved certificate for ${domain} to ${certPath}`);
}
catch (error) {
this.log('error', `Failed to save certificate for ${domain}: ${error}`);
}
}
/**
* Handles SNI (Server Name Indication) for TLS connections
* Used by the HTTPS server to select the correct certificate for each domain
* @private
*/
handleSNI(domain, cb) {
this.log('debug', `SNI request for domain: ${domain}`);
// Check if we have a certificate for this domain
const certs = this.certificateCache.get(domain);
if (certs) {
try {
// Create TLS context with the cached certificate
const context = plugins.tls.createSecureContext({
key: certs.key,
cert: certs.cert
});
this.log('debug', `Using cached certificate for ${domain}`);
cb(null, context);
return;
}
catch (err) {
this.log('error', `Error creating secure context for ${domain}:`, err);
}
}
// Check if we should trigger certificate issuance
if (this.options.acme?.enabled && this.port80Handler && !domain.includes('*')) {
// Check if this domain is already registered
const certData = this.port80Handler.getCertificate(domain);
if (!certData) {
this.log('info', `No certificate found for ${domain}, registering for issuance`);
// Register with new domain options format
const domainOptions = {
domainName: domain,
sslRedirect: true,
acmeMaintenance: true
};
this.port80Handler.addDomain(domainOptions);
}
}
// Fall back to default certificate
try {
const context = plugins.tls.createSecureContext({
key: this.defaultCertificates.key,
cert: this.defaultCertificates.cert
});
this.log('debug', `Using default certificate for ${domain}`);
cb(null, context);
}
catch (err) {
this.log('error', `Error creating default secure context:`, err);
cb(new Error('Cannot create secure context'), null);
}
}
/**
* Starts the proxy server
*/
async start() {
this.startTime = Date.now();
// Initialize Port80Handler if enabled
if (this.options.acme.enabled) {
await this.initializePort80Handler();
}
// Create the HTTPS server
this.httpsServer = plugins.https.createServer({
key: this.defaultCertificates.key,
cert: this.defaultCertificates.cert,
SNICallback: (domain, cb) => this.handleSNI(domain, cb)
}, (req, res) => this.handleRequest(req, res));
// Configure server timeouts
this.httpsServer.keepAliveTimeout = this.options.keepAliveTimeout;
this.httpsServer.headersTimeout = this.options.headersTimeout;
// Setup connection tracking
this.setupConnectionTracking();
// Setup WebSocket support
this.setupWebsocketSupport();
// Start metrics collection
this.setupMetricsCollection();
// Setup connection pool cleanup interval
this.setupConnectionPoolCleanup();
// Start the server
return new Promise((resolve) => {
this.httpsServer.listen(this.options.port, () => {
this.log('info', `NetworkProxy started on port ${this.options.port}`);
resolve();
});
});
}
/**
* Sets up tracking of TCP connections
*/
setupConnectionTracking() {
this.httpsServer.on('connection', (connection) => {
// Check if max connections reached
if (this.socketMap.getArray().length >= this.options.maxConnections) {
this.log('warn', `Max connections (${this.options.maxConnections}) reached, rejecting new connection`);
connection.destroy();
return;
}
// Add connection to tracking
this.socketMap.add(connection);
this.connectedClients = this.socketMap.getArray().length;
// Check for connection from PortProxy by inspecting the source port
// This is a heuristic - in a production environment you might use a more robust method
const localPort = connection.localPort;
const remotePort = connection.remotePort;
// If this connection is from a PortProxy (usually indicated by it coming from localhost)
if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) {
this.portProxyConnections++;
this.log('debug', `New connection from PortProxy (local: ${localPort}, remote: ${remotePort})`);
}
else {
this.log('debug', `New direct connection (local: ${localPort}, remote: ${remotePort})`);
}
// Setup connection cleanup handlers
const cleanupConnection = () => {
if (this.socketMap.checkForObject(connection)) {
this.socketMap.remove(connection);
this.connectedClients = this.socketMap.getArray().length;
// If this was a PortProxy connection, decrement the counter
if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) {
this.portProxyConnections--;
}
this.log('debug', `Connection closed. ${this.connectedClients} connections remaining`);
}
};
connection.on('close', cleanupConnection);
connection.on('error', (err) => {
this.log('debug', 'Connection error', err);
cleanupConnection();
});
connection.on('end', cleanupConnection);
connection.on('timeout', () => {
this.log('debug', 'Connection timeout');
cleanupConnection();
});
});
// Track TLS handshake completions
this.httpsServer.on('secureConnection', (tlsSocket) => {
this.tlsTerminatedConnections++;
this.log('debug', 'TLS handshake completed, connection secured');
});
}
/**
* Sets up WebSocket support
*/
setupWebsocketSupport() {
// Create WebSocket server
this.wsServer = new plugins.ws.WebSocketServer({
server: this.httpsServer,
// Add WebSocket specific timeout
clientTracking: true
});
// Handle WebSocket connections
this.wsServer.on('connection', (wsIncoming, reqArg) => {
this.handleWebSocketConnection(wsIncoming, reqArg);
});
// Set up the heartbeat interval (check every 30 seconds, terminate after 2 minutes of inactivity)
this.heartbeatInterval = setInterval(() => {
if (this.wsServer.clients.size === 0) {
return; // Skip if no active connections
}
this.log('debug', `WebSocket heartbeat check for ${this.wsServer.clients.size} clients`);
this.wsServer.clients.forEach((ws) => {
const wsWithHeartbeat = ws;
if (wsWithHeartbeat.isAlive === false) {
this.log('debug', 'Terminating inactive WebSocket connection');
return wsWithHeartbeat.terminate();
}
wsWithHeartbeat.isAlive = false;
wsWithHeartbeat.ping();
});
}, 30000);
}
/**
* Sets up metrics collection
*/
setupMetricsCollection() {
this.metricsInterval = setInterval(() => {
const uptime = Math.floor((Date.now() - this.startTime) / 1000);
const metrics = {
uptime,
activeConnections: this.connectedClients,
totalRequests: this.requestsServed,
failedRequests: this.failedRequests,
portProxyConnections: this.portProxyConnections,
tlsTerminatedConnections: this.tlsTerminatedConnections,
activeWebSockets: this.wsServer?.clients.size || 0,
memoryUsage: process.memoryUsage(),
activeContexts: Array.from(this.activeContexts),
connectionPool: Object.fromEntries(Array.from(this.connectionPool.entries()).map(([host, connections]) => [
host,
{
total: connections.length,
idle: connections.filter(c => c.isIdle).length
}
]))
};
this.log('debug', 'Proxy metrics', metrics);
}, 60000); // Log metrics every minute
}
/**
* Sets up connection pool cleanup
*/
setupConnectionPoolCleanup() {
// Clean up idle connections every minute
this.connectionPoolCleanupInterval = setInterval(() => {
this.cleanupConnectionPool();
}, 60000); // 1 minute
}
/**
* Handles an incoming WebSocket connection
*/
handleWebSocketConnection(wsIncoming, reqArg) {
const wsPath = reqArg.url;
const wsHost = reqArg.headers.host;
this.log('info', `WebSocket connection for ${wsHost}${wsPath}`);
// Setup heartbeat tracking
wsIncoming.isAlive = true;
wsIncoming.lastPong = Date.now();
wsIncoming.on('pong', () => {
wsIncoming.isAlive = true;
wsIncoming.lastPong = Date.now();
});
// Get the destination configuration
const wsDestinationConfig = this.router.routeReq(reqArg);
if (!wsDestinationConfig) {
this.log('warn', `No route found for WebSocket ${wsHost}${wsPath}`);
wsIncoming.terminate();
return;
}
// Check authentication if required
if (wsDestinationConfig.authentication) {
try {
if (!this.authenticateRequest(reqArg, wsDestinationConfig)) {
this.log('warn', `WebSocket authentication failed for ${wsHost}${wsPath}`);
wsIncoming.terminate();
return;
}
}
catch (error) {
this.log('error', 'WebSocket authentication error', error);
wsIncoming.terminate();
return;
}
}
// Setup outgoing WebSocket connection
let wsOutgoing;
const outGoingDeferred = plugins.smartpromise.defer();
try {
// Select destination IP and port for WebSocket
const wsDestinationIp = this.selectDestinationIp(wsDestinationConfig);
const wsDestinationPort = this.selectDestinationPort(wsDestinationConfig);
const wsTarget = `ws://${wsDestinationIp}:${wsDestinationPort}${reqArg.url}`;
this.log('debug', `Proxying WebSocket to ${wsTarget}`);
wsOutgoing = new plugins.wsDefault(wsTarget);
wsOutgoing.on('open', () => {
this.log('debug', 'Outgoing WebSocket connection established');
outGoingDeferred.resolve();
});
wsOutgoing.on('error', (error) => {
this.log('error', 'Outgoing WebSocket error', error);
outGoingDeferred.reject(error);
if (wsIncoming.readyState === wsIncoming.OPEN) {
wsIncoming.terminate();
}
});
}
catch (err) {
this.log('error', 'Failed to create outgoing WebSocket connection', err);
wsIncoming.terminate();
return;
}
// Handle message forwarding from client to backend
wsIncoming.on('message', async (message, isBinary) => {
try {
// Wait for outgoing connection to be ready
await outGoingDeferred.promise;
// Only forward if both connections are still open
if (wsOutgoing.readyState === wsOutgoing.OPEN) {
wsOutgoing.send(message, { binary: isBinary });
}
}
catch (error) {
this.log('error', 'Error forwarding WebSocket message to backend', error);
}
});
// Handle message forwarding from backend to client
wsOutgoing.on('message', (message, isBinary) => {
try {
// Only forward if the incoming connection is still open
if (wsIncoming.readyState === wsIncoming.OPEN) {
wsIncoming.send(message, { binary: isBinary });
}
}
catch (error) {
this.log('error', 'Error forwarding WebSocket message to client', error);
}
});
// Clean up connections when either side closes
wsIncoming.on('close', (code, reason) => {
this.log('debug', `Incoming WebSocket closed: ${code} - ${reason}`);
if (wsOutgoing && wsOutgoing.readyState !== wsOutgoing.CLOSED) {
try {
// Validate close code (must be 1000-4999) or use 1000 as default
const validCode = (code >= 1000 && code <= 4999) ? code : 1000;
wsOutgoing.close(validCode, reason.toString() || '');
}
catch (error) {
this.log('error', 'Error closing outgoing WebSocket', error);
wsOutgoing.terminate();
}
}
});
wsOutgoing.on('close', (code, reason) => {
this.log('debug', `Outgoing WebSocket closed: ${code} - ${reason}`);
if (wsIncoming && wsIncoming.readyState !== wsIncoming.CLOSED) {
try {
// Validate close code (must be 1000-4999) or use 1000 as default
const validCode = (code >= 1000 && code <= 4999) ? code : 1000;
wsIncoming.close(validCode, reason.toString() || '');
}
catch (error) {
this.log('error', 'Error closing incoming WebSocket', error);
wsIncoming.terminate();
}
}
});
}
/**
* Handles an HTTP/HTTPS request
*/
async handleRequest(originRequest, originResponse) {
this.requestsServed++;
const startTime = Date.now();
const reqId = `req_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
try {
const reqPath = plugins.url.parse(originRequest.url).path;
this.log('info', `[${reqId}] ${originRequest.method} ${originRequest.headers.host}${reqPath}`);
// Handle preflight OPTIONS requests for CORS
if (originRequest.method === 'OPTIONS' && this.options.cors) {
this.handleCorsRequest(originRequest, originResponse);
return;
}
// Get destination configuration
const destinationConfig = this.router.routeReq(originRequest);
if (!destinationConfig) {
this.log('warn', `[${reqId}] No route found for ${originRequest.headers.host}`);
this.sendErrorResponse(originResponse, 404, 'Not Found: No matching route');
this.failedRequests++;
return;
}
// Handle authentication if configured
if (destinationConfig.authentication) {
try {
if (!this.authenticateRequest(originRequest, destinationConfig)) {
this.sendErrorResponse(originResponse, 401, 'Unauthorized', {
'WWW-Authenticate': 'Basic realm="Access to the proxy site", charset="UTF-8"'
});
this.failedRequests++;
return;
}
}
catch (error) {
this.log('error', `[${reqId}] Authentication error`, error);
this.sendErrorResponse(originResponse, 500, 'Internal Server Error: Authentication failed');
this.failedRequests++;
return;
}
}
// Determine if we should use connection pooling
const useConnectionPool = this.options.portProxyIntegration &&
originRequest.socket.remoteAddress?.includes('127.0.0.1');
// Select destination IP and port from the arrays
const destinationIp = this.selectDestinationIp(destinationConfig);
const destinationPort = this.selectDestinationPort(destinationConfig);
// Construct destination URL
const destinationUrl = `http://${destinationIp}:${destinationPort}${originRequest.url}`;
if (useConnectionPool) {
this.log('debug', `[${reqId}] Proxying to ${destinationUrl} (using connection pool)`);
await this.forwardRequestUsingConnectionPool(reqId, originRequest, originResponse, destinationIp, destinationPort, originRequest.url);
}
else {
this.log('debug', `[${reqId}] Proxying to ${destinationUrl}`);
await this.forwardRequest(reqId, originRequest, originResponse, destinationUrl);
}
const processingTime = Date.now() - startTime;
this.log('debug', `[${reqId}] Request completed in ${processingTime}ms`);
}
catch (error) {
this.log('error', `[${reqId}] Unhandled error in request handler`, error);
try {
this.sendErrorResponse(originResponse, 502, 'Bad Gateway: Server error');
}
catch (responseError) {
this.log('error', `[${reqId}] Failed to send error response`, responseError);
}
this.failedRequests++;
}
}
/**
* Handles a CORS preflight request
*/
handleCorsRequest(req, res) {
const cors = this.options.cors;
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', cors.allowOrigin);
res.setHeader('Access-Control-Allow-Methods', cors.allowMethods);
res.setHeader('Access-Control-Allow-Headers', cors.allowHeaders);
res.setHeader('Access-Control-Max-Age', String(cors.maxAge));
// Handle preflight request
res.statusCode = 204;
res.end();
// Count this as a request served
this.requestsServed++;
}
/**
* Authenticates a request against the destination config
*/
authenticateRequest(req, config) {
const authInfo = config.authentication;
if (!authInfo) {
return true; // No authentication required
}
switch (authInfo.type) {
case 'Basic': {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.includes('Basic ')) {
return false;
}
const authStringBase64 = authHeader.replace('Basic ', '');
const authString = plugins.smartstring.base64.decode(authStringBase64);
const [user, pass] = authString.split(':');
// Use constant-time comparison to prevent timing attacks
const userMatch = user === authInfo.user;
const passMatch = pass === authInfo.pass;
return userMatch && passMatch;
}
default:
throw new Error(`Unsupported authentication method: ${authInfo.type}`);
}
}
/**
* Forwards a request to the destination using connection pool
* for optimized connection reuse from PortProxy
*/
async forwardRequestUsingConnectionPool(reqId, originRequest, originResponse, host, port, path) {
try {
// Try to get a connection from the pool
const socket = await this.getConnectionFromPool(host, port);
// Create an HTTP client request using the pooled socket
const reqOptions = {
createConnection: () => socket,
host,
port,
path,
method: originRequest.method,
headers: this.prepareForwardHeaders(originRequest),
timeout: 30000 // 30 second timeout
};
const proxyReq = plugins.http.request(reqOptions);
// Handle timeouts
proxyReq.on('timeout', () => {
this.log('warn', `[${reqId}] Request to ${host}:${port}${path} timed out`);
proxyReq.destroy();
});
// Handle errors
proxyReq.on('error', (err) => {
this.log('error', `[${reqId}] Error in proxy request to ${host}:${port}${path}`, err);
// Check if the client response is still writable
if (!originResponse.writableEnded) {
this.sendErrorResponse(originResponse, 502, 'Bad Gateway: Error communicating with upstream server');
}
// Don't return the socket to the pool on error
try {
if (!socket.destroyed) {
socket.destroy();
}
}
catch (socketErr) {
this.log('error', `[${reqId}] Error destroying socket after request error`, socketErr);
}
});
// Forward request body
originRequest.pipe(proxyReq);
// Handle response
proxyReq.on('response', (proxyRes) => {
// Copy status and headers
originResponse.statusCode = proxyRes.statusCode;
for (const [name, value] of Object.entries(proxyRes.headers)) {
if (value !== undefined) {
originResponse.setHeader(name, value);
}
}
// Forward the response body
proxyRes.pipe(originResponse);
// Return connection to pool when the response completes
proxyRes.on('end', () => {
if (!socket.destroyed) {
this.returnConnectionToPool(socket, host, port);
}
});
proxyRes.on('error', (err) => {
this.log('error', `[${reqId}] Error in proxy response from ${host}:${port}${path}`, err);
// Don't return the socket to the pool on error
try {
if (!socket.destroyed) {
socket.destroy();
}
}
catch (socketErr) {
this.log('error', `[${reqId}] Error destroying socket after response error`, socketErr);
}
});
});
}
catch (error) {
this.log('error', `[${reqId}] Error setting up pooled connection to ${host}:${port}`, error);
this.sendErrorResponse(originResponse, 502, 'Bad Gateway: Unable to reach upstream server');
throw error;
}
}
/**
* Forwards a request to the destination (standard method)
*/
async forwardRequest(reqId, originRequest, originResponse, destinationUrl) {
try {
const proxyRequest = await plugins.smartrequest.request(destinationUrl, {
method: originRequest.method,
headers: this.prepareForwardHeaders(originRequest),
keepAlive: true,
timeout: 30000 // 30 second timeout
}, true, // streaming
(proxyRequestStream) => this.setupRequestStreaming(originRequest, proxyRequestStream));
// Handle the response
this.processProxyResponse(reqId, originResponse, proxyRequest);
}
catch (error) {
this.log('error', `[${reqId}] Error forwarding request`, error);
this.sendErrorResponse(originResponse, 502, 'Bad Gateway: Unable to reach upstream server');
throw error; // Let the main handler catch this
}
}
/**
* Prepares headers to forward to the backend
*/
prepareForwardHeaders(req) {
const safeHeaders = { ...req.headers };
// Add forwarding headers
safeHeaders['X-Forwarded-Host'] = req.headers.host;
safeHeaders['X-Forwarded-Proto'] = 'https';
safeHeaders['X-Forwarded-For'] = (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
// Add proxy-specific headers
safeHeaders['X-Proxy-Id'] = `NetworkProxy-${this.options.port}`;
// If this is coming from PortProxy, add a header to indicate that
if (this.options.portProxyIntegration && req.socket.remoteAddress?.includes('127.0.0.1')) {
safeHeaders['X-PortProxy-Forwarded'] = 'true';
}
// Remove sensitive headers we don't want to forward
const sensitiveHeaders = ['connection', 'upgrade', 'http2-settings'];
for (const header of sensitiveHeaders) {
delete safeHeaders[header];
}
return safeHeaders;
}
/**
* Sets up request streaming for the proxy
*/
setupRequestStreaming(originRequest, proxyRequest) {
// Forward request body data
originRequest.on('data', (chunk) => {
proxyRequest.write(chunk);
});
// End the request when done
originRequest.on('end', () => {
proxyRequest.end();
});
// Handle request errors
originRequest.on('error', (error) => {
this.log('error', 'Error in client request stream', error);
proxyRequest.destroy(error);
});
// Handle client abort/timeout
originRequest.on('close', () => {
if (!originRequest.complete) {
this.log('debug', 'Client closed connection before request completed');
proxyRequest.destroy();
}
});
originRequest.on('timeout', () => {
this.log('debug', 'Client request timeout');
proxyRequest.destroy(new Error('Client request timeout'));
});
// Handle proxy request errors
proxyRequest.on('error', (error) => {
this.log('error', 'Error in outgoing proxy request', error);
});
}
/**
* Processes a proxy response
*/
processProxyResponse(reqId, originResponse, proxyResponse) {
this.log('debug', `[${reqId}] Received upstream response: ${proxyResponse.statusCode}`);
// Set status code
originResponse.statusCode = proxyResponse.statusCode;
// Add default headers
for (const [headerName, headerValue] of Object.entries(this.defaultHeaders)) {
originResponse.setHeader(headerName, headerValue);
}
// Add CORS headers if enabled
if (this.options.cors) {
originResponse.setHeader('Access-Control-Allow-Origin', this.options.cors.allowOrigin);
}
// Copy response headers
for (const [headerName, headerValue] of Object.entries(proxyResponse.headers)) {
// Skip hop-by-hop headers
const hopByHopHeaders = ['connection', 'keep-alive', 'transfer-encoding', 'te',
'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate'];
if (!hopByHopHeaders.includes(headerName.toLowerCase())) {
originResponse.setHeader(headerName, headerValue);
}
}
// Stream response body
proxyResponse.on('data', (chunk) => {
const canContinue = originResponse.write(chunk);
// Apply backpressure if needed
if (!canContinue) {
proxyResponse.pause();
originResponse.once('drain', () => {
proxyResponse.resume();
});
}
});
// End the response when done
proxyResponse.on('end', () => {
originResponse.end();
});
// Handle response errors
proxyResponse.on('error', (error) => {
this.log('error', `[${reqId}] Error in proxy response stream`, error);
originResponse.destroy(error);
});
originResponse.on('error', (error) => {
this.log('error', `[${reqId}] Error in client response stream`, error);
proxyResponse.destroy();
});
}
/**
* Sends an error response to the client
*/
sendErrorResponse(res, statusCode = 500, message = 'Internal Server Error', headers = {}) {
try {
// If headers already sent, just end the response
if (res.headersSent) {
res.end();
return;
}
// Add default headers
for (const [key, value] of Object.entries(this.defaultHeaders)) {
res.setHeader(key, value);
}
// Add provided headers
for (const [key, value] of Object.entries(headers)) {
res.setHeader(key, value);
}
// Send error response
res.writeHead(statusCode, message);
// Send error body as JSON for API clients
if (res.getHeader('Content-Type') === 'application/json') {
res.end(JSON.stringify({ error: { status: statusCode, message } }));
}
else {
// Send as plain text
res.end(message);
}
}
catch (error) {
this.log('error', 'Error sending error response', error);
try {
res.destroy();
}
catch (destroyError) {
// Last resort - nothing more we can do
}
}
}
/**
* Selects a destination IP from the array using round-robin
* @param config The proxy configuration
* @returns A destination IP addres