593 lines
21 KiB
TypeScript
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();
|
|
}
|
|
} |