import * as plugins from '../../plugins.js'; import type { ISmartProxyOptions } from './models/interfaces.js'; import { RouteConnectionHandler } from './route-connection-handler.js'; import { logger } from '../../core/utils/logger.js'; import { cleanupSocket } from '../../core/utils/socket-utils.js'; /** * PortManager handles the dynamic creation and removal of port listeners * * This class provides methods to add and remove listening ports at runtime, * allowing SmartProxy to adapt to configuration changes without requiring * a full restart. * * It includes a reference counting system to track how many routes are using * each port, so ports can be automatically released when they are no longer needed. */ export class PortManager { private servers: Map = new Map(); private settings: ISmartProxyOptions; private routeConnectionHandler: RouteConnectionHandler; private isShuttingDown: boolean = false; // Track how many routes are using each port private portRefCounts: Map = new Map(); /** * Create a new PortManager * * @param settings The SmartProxy settings * @param routeConnectionHandler The handler for new connections */ constructor( settings: ISmartProxyOptions, routeConnectionHandler: RouteConnectionHandler ) { this.settings = settings; this.routeConnectionHandler = routeConnectionHandler; } /** * Start listening on a specific port * * @param port The port number to listen on * @returns Promise that resolves when the server is listening or rejects on error */ public async addPort(port: number): Promise { // Check if we're already listening on this port if (this.servers.has(port)) { // Port is already bound, just increment the reference count this.incrementPortRefCount(port); try { logger.log('debug', `PortManager: Port ${port} is already bound by SmartProxy, reusing binding`, { port, component: 'port-manager' }); } catch (e) { console.log(`[DEBUG] PortManager: Port ${port} is already bound by SmartProxy, reusing binding`); } return; } // Initialize reference count for new port this.portRefCounts.set(port, 1); // Create a server for this port const server = plugins.net.createServer((socket) => { // Check if shutting down if (this.isShuttingDown) { cleanupSocket(socket, 'port-manager-shutdown'); return; } // Delegate to route connection handler this.routeConnectionHandler.handleConnection(socket); }).on('error', (err: Error) => { try { logger.log('error', `Server Error on port ${port}: ${err.message}`, { port, error: err.message, component: 'port-manager' }); } catch (e) { console.error(`[ERROR] Server Error on port ${port}: ${err.message}`); } }); // Start listening on the port return new Promise((resolve, reject) => { server.listen(port, () => { const isHttpProxyPort = this.settings.useHttpProxy?.includes(port); try { logger.log('info', `SmartProxy -> OK: Now listening on port ${port}${ isHttpProxyPort ? ' (HttpProxy forwarding enabled)' : '' }`, { port, isHttpProxyPort: !!isHttpProxyPort, component: 'port-manager' }); } catch (e) { console.log(`[INFO] SmartProxy -> OK: Now listening on port ${port}${ isHttpProxyPort ? ' (HttpProxy forwarding enabled)' : '' }`); } // Store the server reference this.servers.set(port, server); resolve(); }).on('error', (err) => { // Check if this is an external conflict const { isConflict, isExternal } = this.isPortConflict(err); if (isConflict && !isExternal) { // This is an internal conflict (port already bound by SmartProxy) // This shouldn't normally happen because we check servers.has(port) above logger.log('warn', `Port ${port} binding conflict: already in use by SmartProxy`, { port, component: 'port-manager' }); // Still increment reference count to maintain tracking this.incrementPortRefCount(port); resolve(); return; } // Log the error and propagate it logger.log('error', `Failed to listen on port ${port}: ${err.message}`, { port, error: err.message, code: (err as any).code, component: 'port-manager' }); // Clean up reference count since binding failed this.portRefCounts.delete(port); reject(err); }); }); } /** * Stop listening on a specific port * * @param port The port to stop listening on * @returns Promise that resolves when the server is closed */ public async removePort(port: number): Promise { // Decrement the reference count first const newRefCount = this.decrementPortRefCount(port); // If there are still references to this port, keep it open if (newRefCount > 0) { logger.log('debug', `PortManager: Port ${port} still has ${newRefCount} references, keeping open`, { port, refCount: newRefCount, component: 'port-manager' }); return; } // Get the server for this port const server = this.servers.get(port); if (!server) { logger.log('warn', `PortManager: Not listening on port ${port}`, { port, component: 'port-manager' }); // Ensure reference count is reset this.portRefCounts.delete(port); return; } // Close the server return new Promise((resolve) => { server.close((err) => { if (err) { logger.log('error', `Error closing server on port ${port}: ${err.message}`, { port, error: err.message, component: 'port-manager' }); } else { logger.log('info', `SmartProxy -> Stopped listening on port ${port}`, { port, component: 'port-manager' }); } // Remove the server reference and clean up reference counting this.servers.delete(port); this.portRefCounts.delete(port); resolve(); }); }); } /** * Add multiple ports at once * * @param ports Array of ports to add * @returns Promise that resolves when all servers are listening */ public async addPorts(ports: number[]): Promise { const uniquePorts = [...new Set(ports)]; await Promise.all(uniquePorts.map(port => this.addPort(port))); } /** * Remove multiple ports at once * * @param ports Array of ports to remove * @returns Promise that resolves when all servers are closed */ public async removePorts(ports: number[]): Promise { const uniquePorts = [...new Set(ports)]; await Promise.all(uniquePorts.map(port => this.removePort(port))); } /** * Update listening ports to match the provided list * * This will add any ports that aren't currently listening, * and remove any ports that are no longer needed. * * @param ports Array of ports that should be listening * @returns Promise that resolves when all operations are complete */ public async updatePorts(ports: number[]): Promise { const targetPorts = new Set(ports); const currentPorts = new Set(this.servers.keys()); // Find ports to add and remove const portsToAdd = ports.filter(port => !currentPorts.has(port)); const portsToRemove = Array.from(currentPorts).filter(port => !targetPorts.has(port)); // Log the changes if (portsToAdd.length > 0) { console.log(`PortManager: Adding new listeners for ports: ${portsToAdd.join(', ')}`); } if (portsToRemove.length > 0) { console.log(`PortManager: Removing listeners for ports: ${portsToRemove.join(', ')}`); } // Add and remove ports await this.removePorts(portsToRemove); await this.addPorts(portsToAdd); } /** * Get all ports that are currently listening * * @returns Array of port numbers */ public getListeningPorts(): number[] { return Array.from(this.servers.keys()); } /** * Mark the port manager as shutting down */ public setShuttingDown(isShuttingDown: boolean): void { this.isShuttingDown = isShuttingDown; } /** * Close all listening servers * * @returns Promise that resolves when all servers are closed */ public async closeAll(): Promise { const allPorts = Array.from(this.servers.keys()); await this.removePorts(allPorts); } /** * Get all server instances (for testing or debugging) */ public getServers(): Map { return new Map(this.servers); } /** * Check if a port is bound by this SmartProxy instance * * @param port The port number to check * @returns True if the port is currently bound by SmartProxy */ public isPortBoundBySmartProxy(port: number): boolean { return this.servers.has(port); } /** * Get the current reference count for a port * * @param port The port number to check * @returns The number of routes using this port, 0 if none */ public getPortRefCount(port: number): number { return this.portRefCounts.get(port) || 0; } /** * Increment the reference count for a port * * @param port The port number to increment * @returns The new reference count */ public incrementPortRefCount(port: number): number { const currentCount = this.portRefCounts.get(port) || 0; const newCount = currentCount + 1; this.portRefCounts.set(port, newCount); logger.log('debug', `Port ${port} reference count increased to ${newCount}`, { port, refCount: newCount, component: 'port-manager' }); return newCount; } /** * Decrement the reference count for a port * * @param port The port number to decrement * @returns The new reference count */ public decrementPortRefCount(port: number): number { const currentCount = this.portRefCounts.get(port) || 0; if (currentCount <= 0) { logger.log('warn', `Attempted to decrement reference count for port ${port} below zero`, { port, component: 'port-manager' }); return 0; } const newCount = currentCount - 1; this.portRefCounts.set(port, newCount); logger.log('debug', `Port ${port} reference count decreased to ${newCount}`, { port, refCount: newCount, component: 'port-manager' }); return newCount; } /** * Determine if a port binding error is due to an external or internal conflict * * @param error The error object from a failed port binding * @returns Object indicating if this is a conflict and if it's external */ private isPortConflict(error: any): { isConflict: boolean; isExternal: boolean } { if (error.code !== 'EADDRINUSE') { return { isConflict: false, isExternal: false }; } // Check if we already have this port const isBoundInternally = this.servers.has(Number(error.port)); return { isConflict: true, isExternal: !isBoundInternally }; } }