352 lines
11 KiB
TypeScript
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 };
|
|
}
|
|
} |