import * as plugins from '../../plugins.js'; // Importing required components import { ConnectionManager } from './connection-manager.js'; import { SecurityManager } from './security-manager.js'; import { TlsManager } from './tls-manager.js'; import { NetworkProxyBridge } from './network-proxy-bridge.js'; import { TimeoutManager } from './timeout-manager.js'; // import { PortRangeManager } from './port-range-manager.js'; import { RouteManager } from './route-manager.js'; import { RouteConnectionHandler } from './route-connection-handler.js'; // External dependencies import { Port80Handler } from '../../http/port80/port80-handler.js'; import { CertProvisioner } from '../../certificate/providers/cert-provisioner.js'; import type { ICertificateData } from '../../certificate/models/certificate-types.js'; import { buildPort80Handler } from '../../certificate/acme/acme-factory.js'; import { createPort80HandlerOptions } from '../../common/port80-adapter.js'; // Import types and utilities import type { ISmartProxyOptions, IRoutedSmartProxyOptions } from './models/interfaces.js'; import { isRoutedOptions, isLegacyOptions } from './models/interfaces.js'; import type { IRouteConfig } from './models/route-types.js'; /** * SmartProxy - Pure route-based API * * SmartProxy is a unified proxy system that works with routes to define connection handling behavior. * Each route contains matching criteria (ports, domains, etc.) and an action to take (forward, redirect, block). * * Configuration is provided through a set of routes, with each route defining: * - What to match (ports, domains, paths, client IPs) * - What to do with matching traffic (forward, redirect, block) * - How to handle TLS (passthrough, terminate, terminate-and-reencrypt) * - Security settings (IP restrictions, connection limits) * - Advanced options (timeout, headers, etc.) */ export class SmartProxy extends plugins.EventEmitter { private netServers: plugins.net.Server[] = []; private connectionLogger: NodeJS.Timeout | null = null; private isShuttingDown: boolean = false; // Component managers private connectionManager: ConnectionManager; private securityManager: SecurityManager; private tlsManager: TlsManager; private networkProxyBridge: NetworkProxyBridge; private timeoutManager: TimeoutManager; // private portRangeManager: PortRangeManager; private routeManager: RouteManager; private routeConnectionHandler: RouteConnectionHandler; // Port80Handler for ACME certificate management private port80Handler: Port80Handler | null = null; // CertProvisioner for unified certificate workflows private certProvisioner?: CertProvisioner; /** * Constructor for SmartProxy * * @param settingsArg Configuration options containing routes and other settings * Routes define how traffic is matched and handled, with each route having: * - match: criteria for matching traffic (ports, domains, paths, IPs) * - action: what to do with matched traffic (forward, redirect, block) * * Example: * ```ts * const proxy = new SmartProxy({ * routes: [ * { * match: { * ports: 443, * domains: ['example.com', '*.example.com'] * }, * action: { * type: 'forward', * target: { host: '10.0.0.1', port: 8443 }, * tls: { mode: 'passthrough' } * } * } * ], * defaults: { * target: { host: 'localhost', port: 8080 }, * security: { allowedIps: ['*'] } * } * }); * ``` */ constructor(settingsArg: ISmartProxyOptions) { super(); // Set reasonable defaults for all settings this.settings = { ...settingsArg, initialDataTimeout: settingsArg.initialDataTimeout || 120000, socketTimeout: settingsArg.socketTimeout || 3600000, inactivityCheckInterval: settingsArg.inactivityCheckInterval || 60000, maxConnectionLifetime: settingsArg.maxConnectionLifetime || 86400000, inactivityTimeout: settingsArg.inactivityTimeout || 14400000, gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000, noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true, keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true, keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 10000, maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024, disableInactivityCheck: settingsArg.disableInactivityCheck || false, enableKeepAliveProbes: settingsArg.enableKeepAliveProbes !== undefined ? settingsArg.enableKeepAliveProbes : true, enableDetailedLogging: settingsArg.enableDetailedLogging || false, enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false, enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false, allowSessionTicket: settingsArg.allowSessionTicket !== undefined ? settingsArg.allowSessionTicket : true, maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100, connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300, keepAliveTreatment: settingsArg.keepAliveTreatment || 'extended', keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6, extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000, networkProxyPort: settingsArg.networkProxyPort || 8443, }; // Set default ACME options if not provided this.settings.acme = this.settings.acme || {}; if (Object.keys(this.settings.acme).length === 0) { this.settings.acme = { enabled: false, port: 80, accountEmail: 'admin@example.com', useProduction: false, renewThresholdDays: 30, autoRenew: true, certificateStore: './certs', skipConfiguredCerts: false, httpsRedirectPort: 443, renewCheckIntervalHours: 24, routeForwards: [] }; } // Initialize component managers this.timeoutManager = new TimeoutManager(this.settings); this.securityManager = new SecurityManager(this.settings); this.connectionManager = new ConnectionManager( this.settings, this.securityManager, this.timeoutManager ); // Create the route manager this.routeManager = new RouteManager(this.settings); // Create port range manager // this.portRangeManager = new PortRangeManager(this.settings); // Create other required components this.tlsManager = new TlsManager(this.settings); this.networkProxyBridge = new NetworkProxyBridge(this.settings); // Initialize connection handler with route support this.routeConnectionHandler = new RouteConnectionHandler( this.settings, this.connectionManager, this.securityManager, this.tlsManager, this.networkProxyBridge, this.timeoutManager, this.routeManager ); } /** * The settings for the SmartProxy */ public settings: ISmartProxyOptions; /** * Initialize the Port80Handler for ACME certificate management */ private async initializePort80Handler(): Promise { const config = this.settings.acme!; if (!config.enabled) { console.log('ACME is disabled in configuration'); return; } try { // Build and start the Port80Handler this.port80Handler = buildPort80Handler({ ...config, httpsRedirectPort: config.httpsRedirectPort || 443 }); // Share Port80Handler with NetworkProxyBridge before start this.networkProxyBridge.setPort80Handler(this.port80Handler); await this.port80Handler.start(); console.log(`Port80Handler started on port ${config.port}`); } catch (err) { console.log(`Error initializing Port80Handler: ${err}`); } } /** * Start the proxy server with support for both configuration types */ public async start() { // Don't start if already shutting down if (this.isShuttingDown) { console.log("Cannot start SmartProxy while it's shutting down"); return; } // Pure route-based configuration - no domain configs needed // Initialize Port80Handler if enabled await this.initializePort80Handler(); // Initialize CertProvisioner for unified certificate workflows if (this.port80Handler) { const acme = this.settings.acme!; // Setup route forwards const routeForwards = acme.routeForwards?.map(f => f) || []; // Create CertProvisioner with appropriate parameters // No longer need to support multiple configuration types // Just pass the routes directly this.certProvisioner = new CertProvisioner( this.settings.routes, this.port80Handler, this.networkProxyBridge, this.settings.certProvisionFunction, acme.renewThresholdDays!, acme.renewCheckIntervalHours!, acme.autoRenew!, routeForwards ); // Register certificate event handler this.certProvisioner.on('certificate', (certData) => { this.emit('certificate', { domain: certData.domain, publicKey: certData.certificate, privateKey: certData.privateKey, expiryDate: certData.expiryDate, source: certData.source, isRenewal: certData.isRenewal }); }); await this.certProvisioner.start(); console.log('CertProvisioner started'); } // Initialize and start NetworkProxy if needed if (this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0) { await this.networkProxyBridge.initialize(); await this.networkProxyBridge.start(); } // Validate the route configuration const configWarnings = this.routeManager.validateConfiguration(); if (configWarnings.length > 0) { console.log("Route configuration warnings:"); for (const warning of configWarnings) { console.log(` - ${warning}`); } } // Get listening ports from RouteManager const listeningPorts = this.routeManager.getListeningPorts(); // Create servers for each port for (const port of listeningPorts) { const server = plugins.net.createServer((socket) => { // Check if shutting down if (this.isShuttingDown) { socket.end(); socket.destroy(); return; } // Delegate to route connection handler this.routeConnectionHandler.handleConnection(socket); }).on('error', (err: Error) => { console.log(`Server Error on port ${port}: ${err.message}`); }); server.listen(port, () => { const isNetworkProxyPort = this.settings.useNetworkProxy?.includes(port); console.log( `SmartProxy -> OK: Now listening on port ${port}${ isNetworkProxyPort ? ' (NetworkProxy forwarding enabled)' : '' }` ); }); this.netServers.push(server); } // Set up periodic connection logging and inactivity checks this.connectionLogger = setInterval(() => { // Immediately return if shutting down if (this.isShuttingDown) return; // Perform inactivity check this.connectionManager.performInactivityCheck(); // Log connection statistics const now = Date.now(); let maxIncoming = 0; let maxOutgoing = 0; let tlsConnections = 0; let nonTlsConnections = 0; let completedTlsHandshakes = 0; let pendingTlsHandshakes = 0; let keepAliveConnections = 0; let networkProxyConnections = 0; // Get connection records for analysis const connectionRecords = this.connectionManager.getConnections(); // Analyze active connections for (const record of connectionRecords.values()) { // Track connection stats if (record.isTLS) { tlsConnections++; if (record.tlsHandshakeComplete) { completedTlsHandshakes++; } else { pendingTlsHandshakes++; } } else { nonTlsConnections++; } if (record.hasKeepAlive) { keepAliveConnections++; } if (record.usingNetworkProxy) { networkProxyConnections++; } maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime); if (record.outgoingStartTime) { maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime); } } // Get termination stats const terminationStats = this.connectionManager.getTerminationStats(); // Log detailed stats console.log( `Active connections: ${connectionRecords.size}. ` + `Types: TLS=${tlsConnections} (Completed=${completedTlsHandshakes}, Pending=${pendingTlsHandshakes}), ` + `Non-TLS=${nonTlsConnections}, KeepAlive=${keepAliveConnections}, NetworkProxy=${networkProxyConnections}. ` + `Longest running: IN=${plugins.prettyMs(maxIncoming)}, OUT=${plugins.prettyMs(maxOutgoing)}. ` + `Termination stats: ${JSON.stringify({ IN: terminationStats.incoming, OUT: terminationStats.outgoing, })}` ); }, this.settings.inactivityCheckInterval || 60000); // Make sure the interval doesn't keep the process alive if (this.connectionLogger.unref) { this.connectionLogger.unref(); } } /** * Extract domain configurations from routes for certificate provisioning * * Note: This method has been removed as we now work directly with routes */ /** * Stop the proxy server */ public async stop() { console.log('SmartProxy shutting down...'); this.isShuttingDown = true; // Stop CertProvisioner if active if (this.certProvisioner) { await this.certProvisioner.stop(); console.log('CertProvisioner stopped'); } // Stop the Port80Handler if running if (this.port80Handler) { try { await this.port80Handler.stop(); console.log('Port80Handler stopped'); this.port80Handler = null; } catch (err) { console.log(`Error stopping Port80Handler: ${err}`); } } // Stop accepting new connections const closeServerPromises: Promise[] = this.netServers.map( (server) => new Promise((resolve) => { if (!server.listening) { resolve(); return; } server.close((err) => { if (err) { console.log(`Error closing server: ${err.message}`); } resolve(); }); }) ); // Stop the connection logger if (this.connectionLogger) { clearInterval(this.connectionLogger); this.connectionLogger = null; } // Wait for servers to close await Promise.all(closeServerPromises); console.log('All servers closed. Cleaning up active connections...'); // Clean up all active connections this.connectionManager.clearConnections(); // Stop NetworkProxy await this.networkProxyBridge.stop(); // Clear all servers this.netServers = []; console.log('SmartProxy shutdown complete.'); } /** * Updates the domain configurations for the proxy * * Note: This legacy method has been removed. Use updateRoutes instead. */ public async updateDomainConfigs(): Promise { console.warn('Method updateDomainConfigs() is deprecated. Use updateRoutes() instead.'); throw new Error('updateDomainConfigs() is deprecated - use updateRoutes() instead'); } /** * Update routes with new configuration * * This method replaces the current route configuration with the provided routes. * It also provisions certificates for routes that require TLS termination and have * `certificate: 'auto'` set in their TLS configuration. * * @param newRoutes Array of route configurations to use * * Example: * ```ts * proxy.updateRoutes([ * { * match: { ports: 443, domains: 'secure.example.com' }, * action: { * type: 'forward', * target: { host: '10.0.0.1', port: 8443 }, * tls: { mode: 'terminate', certificate: 'auto' } * } * } * ]); * ``` */ public async updateRoutes(newRoutes: IRouteConfig[]): Promise { console.log(`Updating routes (${newRoutes.length} routes)`); // Update routes in RouteManager this.routeManager.updateRoutes(newRoutes); // If NetworkProxy is initialized, resync the configurations if (this.networkProxyBridge.getNetworkProxy()) { await this.networkProxyBridge.syncRoutesToNetworkProxy(newRoutes); } // If Port80Handler is running, provision certificates based on routes if (this.port80Handler && this.settings.acme?.enabled) { // Register all eligible domains from routes this.port80Handler.addDomainsFromRoutes(newRoutes); // Handle static certificates from certProvisionFunction if available if (this.settings.certProvisionFunction) { for (const route of newRoutes) { // Skip routes without domains if (!route.match.domains) continue; // Skip non-forward routes if (route.action.type !== 'forward') continue; // Skip routes without TLS termination if (!route.action.tls || route.action.tls.mode === 'passthrough' || !route.action.target) continue; // Skip certificate provisioning if certificate is not auto if (route.action.tls.certificate !== 'auto') continue; const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; for (const domain of domains) { try { const provision = await this.settings.certProvisionFunction(domain); // Skip http01 as those are handled by Port80Handler if (provision !== 'http01') { // Handle static certificate (e.g., DNS-01 provisioned) const certObj = provision as plugins.tsclass.network.ICert; const certData: ICertificateData = { domain: certObj.domainName, certificate: certObj.publicKey, privateKey: certObj.privateKey, expiryDate: new Date(certObj.validUntil), routeReference: { routeName: route.name } }; this.networkProxyBridge.applyExternalCertificate(certData); console.log(`Applied static certificate for ${domain} from certProvider`); } } catch (err) { console.log(`certProvider error for ${domain}: ${err}`); } } } } console.log('Provisioned certificates for new routes'); } } /** * Request a certificate for a specific domain * * @param domain The domain to request a certificate for * @param routeName Optional route name to associate with the certificate */ public async requestCertificate(domain: string, routeName?: string): Promise { // Validate domain format if (!this.isValidDomain(domain)) { console.log(`Invalid domain format: ${domain}`); return false; } // Use Port80Handler if available if (this.port80Handler) { try { // Check if we already have a certificate const cert = this.port80Handler.getCertificate(domain); if (cert) { console.log(`Certificate already exists for ${domain}, valid until ${cert.expiryDate.toISOString()}`); return true; } // Register domain for certificate issuance this.port80Handler.addDomain({ domain, sslRedirect: true, acmeMaintenance: true, routeReference: routeName ? { routeName } : undefined }); console.log(`Domain ${domain} registered for certificate issuance` + (routeName ? ` for route '${routeName}'` : '')); return true; } catch (err) { console.log(`Error registering domain with Port80Handler: ${err}`); return false; } } // Fall back to NetworkProxyBridge return this.networkProxyBridge.requestCertificate(domain); } /** * Validates if a domain name is valid for certificate issuance */ private isValidDomain(domain: string): boolean { // Very basic domain validation if (!domain || domain.length === 0) { return false; } // Check for wildcard domains (they can't get ACME certs) if (domain.includes('*')) { console.log(`Wildcard domains like "${domain}" are not supported for ACME certificates`); return false; } // Check if domain has at least one dot and no invalid characters const validDomainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; if (!validDomainRegex.test(domain)) { console.log(`Domain "${domain}" has invalid format`); return false; } return true; } /** * Get statistics about current connections */ public getStatistics(): any { const connectionRecords = this.connectionManager.getConnections(); const terminationStats = this.connectionManager.getTerminationStats(); let tlsConnections = 0; let nonTlsConnections = 0; let keepAliveConnections = 0; let networkProxyConnections = 0; // Analyze active connections for (const record of connectionRecords.values()) { if (record.isTLS) tlsConnections++; else nonTlsConnections++; if (record.hasKeepAlive) keepAliveConnections++; if (record.usingNetworkProxy) networkProxyConnections++; } return { activeConnections: connectionRecords.size, tlsConnections, nonTlsConnections, keepAliveConnections, networkProxyConnections, terminationStats, acmeEnabled: !!this.port80Handler, port80HandlerPort: this.port80Handler ? this.settings.acme?.port : null, routes: this.routeManager.getListeningPorts().length }; } /** * Get a list of eligible domains for ACME certificates */ public getEligibleDomainsForCertificates(): string[] { const domains: string[] = []; // Get domains from routes const routes = isRoutedOptions(this.settings) ? this.settings.routes : []; for (const route of routes) { if (!route.match.domains) continue; // Skip routes without TLS termination or auto certificates if (route.action.type !== 'forward' || !route.action.tls || route.action.tls.mode === 'passthrough' || route.action.tls.certificate !== 'auto') continue; const routeDomains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; // Skip domains that can't be used with ACME const eligibleDomains = routeDomains.filter(domain => !domain.includes('*') && this.isValidDomain(domain) ); domains.push(...eligibleDomains); } // Legacy mode is no longer supported return domains; } /** * Get status of certificates managed by Port80Handler */ public getCertificateStatus(): any { if (!this.port80Handler) { return { enabled: false, message: 'Port80Handler is not enabled' }; } // Get eligible domains const eligibleDomains = this.getEligibleDomainsForCertificates(); const certificateStatus: Record = {}; // Check each domain for (const domain of eligibleDomains) { const cert = this.port80Handler.getCertificate(domain); if (cert) { const now = new Date(); const expiryDate = cert.expiryDate; const daysRemaining = Math.floor((expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)); certificateStatus[domain] = { status: 'valid', expiryDate: expiryDate.toISOString(), daysRemaining, renewalNeeded: daysRemaining <= (this.settings.acme?.renewThresholdDays ?? 0) }; } else { certificateStatus[domain] = { status: 'missing', message: 'No certificate found' }; } } const acme = this.settings.acme!; return { enabled: true, port: acme.port!, useProduction: acme.useProduction!, autoRenew: acme.autoRenew!, certificates: certificateStatus }; } }