smartproxy/ts/proxies/network-proxy/network-proxy.ts

593 lines
21 KiB
TypeScript

import * as plugins from '../../plugins.js';
import {
createLogger,
RouteManager,
convertLegacyConfigToRouteConfig
} from './models/types.js';
import type {
INetworkProxyOptions,
ILogger,
IReverseProxyConfig
} from './models/types.js';
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
import type { IRouteContext, IHttpRouteContext } from '../../core/models/route-context.js';
import { createBaseRouteContext } from '../../core/models/route-context.js';
import { CertificateManager } from './certificate-manager.js';
import { ConnectionPool } from './connection-pool.js';
import { RequestHandler, type IMetricsTracker } from './request-handler.js';
import { WebSocketHandler } from './websocket-handler.js';
import { ProxyRouter } from '../../http/router/index.js';
import { RouteRouter } from '../../http/router/route-router.js';
import { Port80Handler } from '../../http/port80/port80-handler.js';
import { FunctionCache } from './function-cache.js';
/**
* NetworkProxy provides a reverse proxy with TLS termination, WebSocket support,
* automatic certificate management, and high-performance connection pooling.
*/
export class NetworkProxy implements IMetricsTracker {
// Provide a minimal JSON representation to avoid circular references during deep equality checks
public toJSON(): any {
return {};
}
// Configuration
public options: INetworkProxyOptions;
public routes: IRouteConfig[] = [];
// Server instances (HTTP/2 with HTTP/1 fallback)
public httpsServer: any;
// Core components
private certificateManager: CertificateManager;
private connectionPool: ConnectionPool;
private requestHandler: RequestHandler;
private webSocketHandler: WebSocketHandler;
private legacyRouter = new ProxyRouter(); // Legacy router for backward compatibility
private router = new RouteRouter(); // New modern router
private routeManager: RouteManager;
private functionCache: FunctionCache;
// State tracking
public socketMap = new plugins.lik.ObjectMap<plugins.net.Socket>();
public activeContexts: Set<string> = new Set();
public connectedClients: number = 0;
public startTime: number = 0;
public requestsServed: number = 0;
public failedRequests: number = 0;
// Tracking for SmartProxy integration
private portProxyConnections: number = 0;
private tlsTerminatedConnections: number = 0;
// Timers
private metricsInterval: NodeJS.Timeout;
private connectionPoolCleanupInterval: NodeJS.Timeout;
// Logger
private logger: ILogger;
/**
* Creates a new NetworkProxy instance
*/
constructor(optionsArg: INetworkProxyOptions) {
// 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
},
// Defaults for SmartProxy integration
connectionPoolSize: optionsArg.connectionPoolSize || 50,
portProxyIntegration: optionsArg.portProxyIntegration || false,
useExternalPort80Handler: optionsArg.useExternalPort80Handler || false,
// Backend protocol (http1 or http2)
backendProtocol: optionsArg.backendProtocol || 'http1',
// Default ACME options
acme: {
enabled: optionsArg.acme?.enabled || false,
port: optionsArg.acme?.port || 80,
accountEmail: optionsArg.acme?.accountEmail || '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
}
};
// Initialize logger
this.logger = createLogger(this.options.logLevel);
// Initialize route manager
this.routeManager = new RouteManager(this.logger);
// Initialize function cache
this.functionCache = new FunctionCache(this.logger, {
maxCacheSize: this.options.functionCacheSize || 1000,
defaultTtl: this.options.functionCacheTtl || 5000
});
// Initialize other components
this.certificateManager = new CertificateManager(this.options);
this.connectionPool = new ConnectionPool(this.options);
this.requestHandler = new RequestHandler(
this.options,
this.connectionPool,
this.legacyRouter, // Still use legacy router for backward compatibility
this.routeManager,
this.functionCache,
this.router // Pass the new modern router as well
);
this.webSocketHandler = new WebSocketHandler(
this.options,
this.connectionPool,
this.legacyRouter,
this.routes // Pass current routes to WebSocketHandler
);
// Connect request handler to this metrics tracker
this.requestHandler.setMetricsTracker(this);
// Initialize with any provided routes
if (this.options.routes && this.options.routes.length > 0) {
this.updateRouteConfigs(this.options.routes);
}
}
/**
* Implements IMetricsTracker interface to increment request counters
*/
public incrementRequestsServed(): void {
this.requestsServed++;
}
/**
* Implements IMetricsTracker interface to increment failed request counters
*/
public incrementFailedRequests(): void {
this.failedRequests++;
}
/**
* Returns the port number this NetworkProxy is listening on
* Useful for SmartProxy to determine where to forward connections
*/
public getListeningPort(): number {
// If the server is running, get the actual listening port
if (this.httpsServer && this.httpsServer.address()) {
const address = this.httpsServer.address();
if (address && typeof address === 'object' && 'port' in address) {
return address.port;
}
}
// Fallback to configured port
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
*/
public updateCapacity(maxConnections?: number, keepAliveTimeout?: number, connectionPoolSize?: number): void {
if (maxConnections !== undefined) {
this.options.maxConnections = maxConnections;
this.logger.info(`Updated max connections to ${maxConnections}`);
}
if (keepAliveTimeout !== undefined) {
this.options.keepAliveTimeout = keepAliveTimeout;
if (this.httpsServer) {
this.httpsServer.keepAliveTimeout = keepAliveTimeout;
this.logger.info(`Updated keep-alive timeout to ${keepAliveTimeout}ms`);
}
}
if (connectionPoolSize !== undefined) {
this.options.connectionPoolSize = connectionPoolSize;
this.logger.info(`Updated connection pool size to ${connectionPoolSize}`);
// Clean up excess connections in the pool
this.connectionPool.cleanupConnectionPool();
}
}
/**
* Returns current server metrics
* Useful for SmartProxy to determine which NetworkProxy to use for load balancing
*/
public getMetrics(): any {
return {
activeConnections: this.connectedClients,
totalRequests: this.requestsServed,
failedRequests: this.failedRequests,
portProxyConnections: this.portProxyConnections,
tlsTerminatedConnections: this.tlsTerminatedConnections,
connectionPoolSize: this.connectionPool.getPoolStatus(),
uptime: Math.floor((Date.now() - this.startTime) / 1000),
memoryUsage: process.memoryUsage(),
activeWebSockets: this.webSocketHandler.getConnectionInfo().activeConnections,
functionCache: this.functionCache.getStats()
};
}
/**
* Sets an external Port80Handler for certificate management
* This allows the NetworkProxy to use a centrally managed Port80Handler
* instead of creating its own
*
* @param handler The Port80Handler instance to use
*/
public setExternalPort80Handler(handler: Port80Handler): void {
// Connect it to the certificate manager
this.certificateManager.setExternalPort80Handler(handler);
}
/**
* Starts the proxy server
*/
public async start(): Promise<void> {
this.startTime = Date.now();
// Initialize Port80Handler if enabled and not using external handler
if (this.options.acme?.enabled && !this.options.useExternalPort80Handler) {
await this.certificateManager.initializePort80Handler();
}
// Create HTTP/2 server with HTTP/1 fallback
this.httpsServer = plugins.http2.createSecureServer(
{
key: this.certificateManager.getDefaultCertificates().key,
cert: this.certificateManager.getDefaultCertificates().cert,
allowHTTP1: true,
ALPNProtocols: ['h2', 'http/1.1']
}
);
// Track raw TCP connections for metrics and limits
this.setupConnectionTracking();
// Handle incoming HTTP/2 streams
this.httpsServer.on('stream', (stream: any, headers: any) => {
this.requestHandler.handleHttp2(stream, headers);
});
// Handle HTTP/1.x fallback requests
this.httpsServer.on('request', (req: any, res: any) => {
this.requestHandler.handleRequest(req, res);
});
// Share server with certificate manager for dynamic contexts
this.certificateManager.setHttpsServer(this.httpsServer);
// Setup WebSocket support on HTTP/1 fallback
this.webSocketHandler.initialize(this.httpsServer);
// Start metrics logging
this.setupMetricsCollection();
// Start periodic connection pool cleanup
this.connectionPoolCleanupInterval = this.connectionPool.setupPeriodicCleanup();
// Start the server
return new Promise((resolve) => {
this.httpsServer.listen(this.options.port, () => {
this.logger.info(`NetworkProxy started on port ${this.options.port}`);
resolve();
});
});
}
/**
* Sets up tracking of TCP connections
*/
private setupConnectionTracking(): void {
this.httpsServer.on('connection', (connection: plugins.net.Socket) => {
// Check if max connections reached
if (this.socketMap.getArray().length >= this.options.maxConnections) {
this.logger.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 SmartProxy by inspecting the source port
const localPort = connection.localPort || 0;
const remotePort = connection.remotePort || 0;
// If this connection is from a SmartProxy (usually indicated by it coming from localhost)
if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) {
this.portProxyConnections++;
this.logger.debug(`New connection from SmartProxy (local: ${localPort}, remote: ${remotePort})`);
} else {
this.logger.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 SmartProxy connection, decrement the counter
if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) {
this.portProxyConnections--;
}
this.logger.debug(`Connection closed. ${this.connectedClients} connections remaining`);
}
};
connection.on('close', cleanupConnection);
connection.on('error', (err) => {
this.logger.debug('Connection error', err);
cleanupConnection();
});
connection.on('end', cleanupConnection);
});
// Track TLS handshake completions
this.httpsServer.on('secureConnection', (tlsSocket) => {
this.tlsTerminatedConnections++;
this.logger.debug('TLS handshake completed, connection secured');
});
}
/**
* Sets up metrics collection
*/
private setupMetricsCollection(): void {
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.webSocketHandler.getConnectionInfo().activeConnections,
memoryUsage: process.memoryUsage(),
activeContexts: Array.from(this.activeContexts),
connectionPool: this.connectionPool.getPoolStatus()
};
this.logger.debug('Proxy metrics', metrics);
}, 60000); // Log metrics every minute
// Don't keep process alive just for metrics
if (this.metricsInterval.unref) {
this.metricsInterval.unref();
}
}
/**
* Updates the route configurations - this is the primary method for configuring NetworkProxy
* @param routes The new route configurations to use
*/
public async updateRouteConfigs(routes: IRouteConfig[]): Promise<void> {
this.logger.info(`Updating route configurations (${routes.length} routes)`);
// Update routes in RouteManager, modern router, WebSocketHandler, and SecurityManager
this.routeManager.updateRoutes(routes);
this.router.setRoutes(routes);
this.webSocketHandler.setRoutes(routes);
this.requestHandler.securityManager.setRoutes(routes);
this.routes = routes;
// Directly update the certificate manager with the new routes
// This will extract domains and handle certificate provisioning
this.certificateManager.updateRouteConfigs(routes);
// Collect all domains and certificates for configuration
const currentHostnames = new Set<string>();
const certificateUpdates = new Map<string, { cert: string, key: string }>();
// Process each route to extract domain and certificate information
for (const route of routes) {
// Skip non-forward routes or routes without domains
if (route.action.type !== 'forward' || !route.match.domains) {
continue;
}
// Get domains from route
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
// Process each domain
for (const domain of domains) {
// Skip wildcard domains for direct host configuration
if (domain.includes('*')) {
continue;
}
currentHostnames.add(domain);
// Check if we have a static certificate for this domain
if (route.action.tls?.certificate && route.action.tls.certificate !== 'auto') {
certificateUpdates.set(domain, {
cert: route.action.tls.certificate.cert,
key: route.action.tls.certificate.key
});
}
}
}
// Update certificate cache with any static certificates
for (const [domain, certData] of certificateUpdates.entries()) {
try {
this.certificateManager.updateCertificateCache(
domain,
certData.cert,
certData.key
);
this.activeContexts.add(domain);
} catch (error) {
this.logger.error(`Failed to add SSL context for ${domain}`, error);
}
}
// Clean up removed contexts
for (const hostname of this.activeContexts) {
if (!currentHostnames.has(hostname)) {
this.logger.info(`Hostname ${hostname} removed from configuration`);
this.activeContexts.delete(hostname);
}
}
// Create legacy proxy configs for the router
// This is only needed for backward compatibility with ProxyRouter
// and will be removed in the future
const legacyConfigs: IReverseProxyConfig[] = [];
for (const domain of currentHostnames) {
// Find route for this domain
const route = routes.find(r => {
const domains = Array.isArray(r.match.domains) ? r.match.domains : [r.match.domains];
return domains.includes(domain);
});
if (!route || route.action.type !== 'forward' || !route.action.target) {
continue;
}
// Skip routes with function-based targets - we'll handle them during request processing
if (typeof route.action.target.host === 'function' || typeof route.action.target.port === 'function') {
this.logger.info(`Domain ${domain} uses function-based targets - will be handled at request time`);
continue;
}
// Extract static target information
const targetHosts = Array.isArray(route.action.target.host)
? route.action.target.host
: [route.action.target.host];
const targetPort = route.action.target.port;
// Get certificate information
const certData = certificateUpdates.get(domain);
const defaultCerts = this.certificateManager.getDefaultCertificates();
legacyConfigs.push({
hostName: domain,
destinationIps: targetHosts,
destinationPorts: [targetPort],
privateKey: certData?.key || defaultCerts.key,
publicKey: certData?.cert || defaultCerts.cert
});
}
// Update the router with legacy configs
// Handle both old and new router interfaces
if (typeof this.router.setRoutes === 'function') {
this.router.setRoutes(routes);
} else if (typeof this.router.setNewProxyConfigs === 'function') {
this.router.setNewProxyConfigs(legacyConfigs);
} else {
this.logger.warn('Router has no recognized configuration method');
}
this.logger.info(`Route configuration updated with ${routes.length} routes and ${legacyConfigs.length} proxy configs`);
}
// Legacy methods have been removed.
// Please use updateRouteConfigs() directly with modern route-based configuration.
/**
* Adds default headers to be included in all responses
*/
public async addDefaultHeaders(headersArg: { [key: string]: string }): Promise<void> {
this.logger.info('Adding default headers', headersArg);
this.requestHandler.setDefaultHeaders(headersArg);
}
/**
* Stops the proxy server
*/
public async stop(): Promise<void> {
this.logger.info('Stopping NetworkProxy server');
// Clear intervals
if (this.metricsInterval) {
clearInterval(this.metricsInterval);
}
if (this.connectionPoolCleanupInterval) {
clearInterval(this.connectionPoolCleanupInterval);
}
// Stop WebSocket handler
this.webSocketHandler.shutdown();
// Close all tracked sockets
for (const socket of this.socketMap.getArray()) {
try {
socket.destroy();
} catch (error) {
this.logger.error('Error destroying socket', error);
}
}
// Close all connection pool connections
this.connectionPool.closeAllConnections();
// Stop Port80Handler if internally managed
await this.certificateManager.stopPort80Handler();
// Close the HTTPS server
return new Promise((resolve) => {
this.httpsServer.close(() => {
this.logger.info('NetworkProxy server stopped successfully');
resolve();
});
});
}
/**
* Requests a new certificate for a domain
* This can be used to manually trigger certificate issuance
* @param domain The domain to request a certificate for
* @returns A promise that resolves when the request is submitted (not when the certificate is issued)
*/
public async requestCertificate(domain: string): Promise<boolean> {
return this.certificateManager.requestCertificate(domain);
}
/**
* Update certificate for a domain
*
* This method allows direct updates of certificates from external sources
* like Port80Handler or custom certificate providers.
*
* @param domain The domain to update certificate for
* @param certificate The new certificate (public key)
* @param privateKey The new private key
* @param expiryDate Optional expiry date
*/
public updateCertificate(
domain: string,
certificate: string,
privateKey: string,
expiryDate?: Date
): void {
this.logger.info(`Updating certificate for ${domain}`);
this.certificateManager.updateCertificateCache(domain, certificate, privateKey, expiryDate);
}
/**
* Gets all route configurations currently in use
*/
public getRouteConfigs(): IRouteConfig[] {
return this.routeManager.getRoutes();
}
}