smartproxy/ts/proxies/smart-proxy/port-manager.ts

352 lines
11 KiB
TypeScript

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';
/**
* 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<number, plugins.net.Server> = new Map();
private settings: ISmartProxyOptions;
private routeConnectionHandler: RouteConnectionHandler;
private isShuttingDown: boolean = false;
// Track how many routes are using each port
private portRefCounts: Map<number, number> = 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<void> {
// 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);
logger.log('debug', `PortManager: Port ${port} is already bound by SmartProxy, reusing binding`, {
port,
component: 'port-manager'
});
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) {
socket.end();
socket.destroy();
return;
}
// Delegate to route connection handler
this.routeConnectionHandler.handleConnection(socket);
}).on('error', (err: Error) => {
logger.log('error', `Server Error on port ${port}: ${err.message}`, {
port,
error: err.message,
component: 'port-manager'
});
});
// Start listening on the port
return new Promise<void>((resolve, reject) => {
server.listen(port, () => {
const isHttpProxyPort = this.settings.useHttpProxy?.includes(port);
logger.log('info', `SmartProxy -> OK: Now listening on port ${port}${
isHttpProxyPort ? ' (HttpProxy forwarding enabled)' : ''
}`, {
port,
isHttpProxyPort: !!isHttpProxyPort,
component: 'port-manager'
});
// 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<void> {
// 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<void>((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<void> {
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<void> {
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<void> {
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<void> {
const allPorts = Array.from(this.servers.keys());
await this.removePorts(allPorts);
}
/**
* Get all server instances (for testing or debugging)
*/
public getServers(): Map<number, plugins.net.Server> {
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 };
}
}