fix(routing): unify route based architecture

This commit is contained in:
2025-05-13 12:48:41 +00:00
parent fe632bde67
commit fcc8cf9caa
46 changed files with 7943 additions and 1332 deletions

View File

@ -1,441 +0,0 @@
import * as plugins from '../../plugins.js';
import type { IDomainConfig, ISmartProxyOptions } from './models/interfaces.js';
import type { TForwardingType, IForwardConfig } from '../../forwarding/config/forwarding-types.js';
import type { ForwardingHandler } from '../../forwarding/handlers/base-handler.js';
import { ForwardingHandlerFactory } from '../../forwarding/factory/forwarding-factory.js';
import type { IRouteConfig } from './models/route-types.js';
import { RouteManager } from './route-manager.js';
/**
* Manages domain configurations and target selection
*/
export class DomainConfigManager {
// Track round-robin indices for domain configs
private domainTargetIndices: Map<IDomainConfig, number> = new Map();
// Cache forwarding handlers for each domain config
private forwardingHandlers: Map<IDomainConfig, ForwardingHandler> = new Map();
// Store derived domain configs from routes
private derivedDomainConfigs: IDomainConfig[] = [];
// Reference to RouteManager for route-based configuration
private routeManager?: RouteManager;
constructor(private settings: ISmartProxyOptions) {
// Initialize with derived domain configs if using route-based configuration
if (settings.routes && !settings.domainConfigs) {
this.generateDomainConfigsFromRoutes();
}
}
/**
* Set the route manager reference for route-based queries
*/
public setRouteManager(routeManager: RouteManager): void {
this.routeManager = routeManager;
// Regenerate domain configs from routes if needed
if (this.settings.routes && (!this.settings.domainConfigs || this.settings.domainConfigs.length === 0)) {
this.generateDomainConfigsFromRoutes();
}
}
/**
* Generate domain configs from routes
*/
public generateDomainConfigsFromRoutes(): void {
this.derivedDomainConfigs = [];
if (!this.settings.routes) return;
for (const route of this.settings.routes) {
if (route.action.type !== 'forward' || !route.match.domains) continue;
// Convert route to domain config
const domainConfig = this.routeToDomainConfig(route);
if (domainConfig) {
this.derivedDomainConfigs.push(domainConfig);
}
}
}
/**
* Convert a route to a domain config
*/
private routeToDomainConfig(route: IRouteConfig): IDomainConfig | null {
if (route.action.type !== 'forward' || !route.action.target) return null;
// Get domains from route
const domains = Array.isArray(route.match.domains) ?
route.match.domains :
(route.match.domains ? [route.match.domains] : []);
if (domains.length === 0) return null;
// Determine forwarding type based on TLS mode
let forwardingType: TForwardingType = 'http-only';
if (route.action.tls) {
switch (route.action.tls.mode) {
case 'passthrough':
forwardingType = 'https-passthrough';
break;
case 'terminate':
forwardingType = 'https-terminate-to-http';
break;
case 'terminate-and-reencrypt':
forwardingType = 'https-terminate-to-https';
break;
}
}
// Create domain config
return {
domains,
forwarding: {
type: forwardingType,
target: {
host: route.action.target.host,
port: route.action.target.port
},
security: route.action.security ? {
allowedIps: route.action.security.allowedIps,
blockedIps: route.action.security.blockedIps,
maxConnections: route.action.security.maxConnections
} : undefined,
https: route.action.tls && route.action.tls.certificate !== 'auto' ? {
customCert: route.action.tls.certificate
} : undefined,
advanced: route.action.advanced
}
};
}
/**
* Updates the domain configurations
*/
public updateDomainConfigs(newDomainConfigs: IDomainConfig[]): void {
// If we're using domainConfigs property, update it
if (this.settings.domainConfigs) {
this.settings.domainConfigs = newDomainConfigs;
} else {
// Otherwise update our derived configs
this.derivedDomainConfigs = newDomainConfigs;
}
// Reset target indices for removed configs
const currentConfigSet = new Set(newDomainConfigs);
for (const [config] of this.domainTargetIndices) {
if (!currentConfigSet.has(config)) {
this.domainTargetIndices.delete(config);
}
}
// Clear handlers for removed configs and create handlers for new configs
const handlersToRemove: IDomainConfig[] = [];
for (const [config] of this.forwardingHandlers) {
if (!currentConfigSet.has(config)) {
handlersToRemove.push(config);
}
}
// Remove handlers that are no longer needed
for (const config of handlersToRemove) {
this.forwardingHandlers.delete(config);
}
// Create handlers for new configs
for (const config of newDomainConfigs) {
if (!this.forwardingHandlers.has(config)) {
try {
const handler = this.createForwardingHandler(config);
this.forwardingHandlers.set(config, handler);
} catch (err) {
console.log(`Error creating forwarding handler for domain ${config.domains.join(', ')}: ${err}`);
}
}
}
}
/**
* Get all domain configurations
*/
public getDomainConfigs(): IDomainConfig[] {
// Use domainConfigs from settings if available, otherwise use derived configs
return this.settings.domainConfigs || this.derivedDomainConfigs;
}
/**
* Find domain config matching a server name
*/
public findDomainConfig(serverName: string): IDomainConfig | undefined {
if (!serverName) return undefined;
// Get domain configs from the appropriate source
const domainConfigs = this.getDomainConfigs();
// Check for direct match
for (const config of domainConfigs) {
if (config.domains.some(d => plugins.minimatch(serverName, d))) {
return config;
}
}
// No match found
return undefined;
}
/**
* Find domain config for a specific port
*/
public findDomainConfigForPort(port: number): IDomainConfig | undefined {
// Get domain configs from the appropriate source
const domainConfigs = this.getDomainConfigs();
// Check if any domain config has a matching port range
for (const domain of domainConfigs) {
const portRanges = domain.forwarding?.advanced?.portRanges;
if (portRanges && portRanges.length > 0 && this.isPortInRanges(port, portRanges)) {
return domain;
}
}
// If we're in route-based mode, also check routes for this port
if (this.settings.routes && (!this.settings.domainConfigs || this.settings.domainConfigs.length === 0)) {
const routesForPort = this.settings.routes.filter(route => {
// Check if this port is in the route's ports
if (typeof route.match.ports === 'number') {
return route.match.ports === port;
} else if (Array.isArray(route.match.ports)) {
return route.match.ports.some(p => {
if (typeof p === 'number') {
return p === port;
} else if (p.from && p.to) {
return port >= p.from && port <= p.to;
}
return false;
});
}
return false;
});
// If we found any routes for this port, convert the first one to a domain config
if (routesForPort.length > 0 && routesForPort[0].action.type === 'forward') {
const domainConfig = this.routeToDomainConfig(routesForPort[0]);
if (domainConfig) {
return domainConfig;
}
}
}
return undefined;
}
/**
* Check if a port is within any of the given ranges
*/
public isPortInRanges(port: number, ranges: Array<{ from: number; to: number }>): boolean {
return ranges.some((range) => port >= range.from && port <= range.to);
}
/**
* Get target IP with round-robin support
*/
public getTargetIP(domainConfig: IDomainConfig): string {
const targetHosts = Array.isArray(domainConfig.forwarding.target.host)
? domainConfig.forwarding.target.host
: [domainConfig.forwarding.target.host];
if (targetHosts.length > 0) {
const currentIndex = this.domainTargetIndices.get(domainConfig) || 0;
const ip = targetHosts[currentIndex % targetHosts.length];
this.domainTargetIndices.set(domainConfig, currentIndex + 1);
return ip;
}
return this.settings.targetIP || 'localhost';
}
/**
* Get target host with round-robin support (for tests)
* This is just an alias for getTargetIP for easier test compatibility
*/
public getTargetHost(domainConfig: IDomainConfig): string {
return this.getTargetIP(domainConfig);
}
/**
* Get target port from domain config
*/
public getTargetPort(domainConfig: IDomainConfig, defaultPort: number): number {
return domainConfig.forwarding.target.port || defaultPort;
}
/**
* Checks if a domain should use NetworkProxy
*/
public shouldUseNetworkProxy(domainConfig: IDomainConfig): boolean {
const forwardingType = this.getForwardingType(domainConfig);
return forwardingType === 'https-terminate-to-http' ||
forwardingType === 'https-terminate-to-https';
}
/**
* Gets the NetworkProxy port for a domain
*/
public getNetworkProxyPort(domainConfig: IDomainConfig): number | undefined {
// First check if we should use NetworkProxy at all
if (!this.shouldUseNetworkProxy(domainConfig)) {
return undefined;
}
return domainConfig.forwarding.advanced?.networkProxyPort || this.settings.networkProxyPort;
}
/**
* Get effective allowed and blocked IPs for a domain
*
* This method combines domain-specific security rules from the forwarding configuration
* with global security defaults when necessary.
*/
public getEffectiveIPRules(domainConfig: IDomainConfig): {
allowedIPs: string[],
blockedIPs: string[]
} {
// Start with empty arrays
const allowedIPs: string[] = [];
const blockedIPs: string[] = [];
// Add IPs from forwarding security settings if available
if (domainConfig.forwarding?.security?.allowedIps) {
allowedIPs.push(...domainConfig.forwarding.security.allowedIps);
} else {
// If no allowed IPs are specified in forwarding config and global defaults exist, use them
if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) {
allowedIPs.push(...this.settings.defaultAllowedIPs);
} else {
// Default to allow all if no specific rules
allowedIPs.push('*');
}
}
// Add blocked IPs from forwarding security settings if available
if (domainConfig.forwarding?.security?.blockedIps) {
blockedIPs.push(...domainConfig.forwarding.security.blockedIps);
}
// Always add global blocked IPs, even if domain has its own rules
// This ensures that global blocks take precedence
if (this.settings.defaultBlockedIPs && this.settings.defaultBlockedIPs.length > 0) {
// Add only unique IPs that aren't already in the list
for (const ip of this.settings.defaultBlockedIPs) {
if (!blockedIPs.includes(ip)) {
blockedIPs.push(ip);
}
}
}
return {
allowedIPs,
blockedIPs
};
}
/**
* Get connection timeout for a domain
*/
public getConnectionTimeout(domainConfig?: IDomainConfig): number {
if (domainConfig?.forwarding.advanced?.timeout) {
return domainConfig.forwarding.advanced.timeout;
}
return this.settings.maxConnectionLifetime || 86400000; // 24 hours default
}
/**
* Creates a forwarding handler for a domain configuration
*/
private createForwardingHandler(domainConfig: IDomainConfig): ForwardingHandler {
// Create a new handler using the factory
const handler = ForwardingHandlerFactory.createHandler(domainConfig.forwarding);
// Initialize the handler
handler.initialize().catch(err => {
console.log(`Error initializing forwarding handler for ${domainConfig.domains.join(', ')}: ${err}`);
});
return handler;
}
/**
* Gets a forwarding handler for a domain config
* If no handler exists, creates one
*/
public getForwardingHandler(domainConfig: IDomainConfig): ForwardingHandler {
// If we already have a handler, return it
if (this.forwardingHandlers.has(domainConfig)) {
return this.forwardingHandlers.get(domainConfig)!;
}
// Otherwise create a new handler
const handler = this.createForwardingHandler(domainConfig);
this.forwardingHandlers.set(domainConfig, handler);
return handler;
}
/**
* Gets the forwarding type for a domain config
*/
public getForwardingType(domainConfig?: IDomainConfig): TForwardingType | undefined {
if (!domainConfig?.forwarding) return undefined;
return domainConfig.forwarding.type;
}
/**
* Checks if the forwarding type requires TLS termination
*/
public requiresTlsTermination(domainConfig?: IDomainConfig): boolean {
if (!domainConfig) return false;
const forwardingType = this.getForwardingType(domainConfig);
return forwardingType === 'https-terminate-to-http' ||
forwardingType === 'https-terminate-to-https';
}
/**
* Checks if the forwarding type supports HTTP
*/
public supportsHttp(domainConfig?: IDomainConfig): boolean {
if (!domainConfig) return false;
const forwardingType = this.getForwardingType(domainConfig);
// HTTP-only always supports HTTP
if (forwardingType === 'http-only') return true;
// For termination types, check the HTTP settings
if (forwardingType === 'https-terminate-to-http' ||
forwardingType === 'https-terminate-to-https') {
// HTTP is supported by default for termination types
return domainConfig.forwarding?.http?.enabled !== false;
}
// HTTPS-passthrough doesn't support HTTP
return false;
}
/**
* Checks if HTTP requests should be redirected to HTTPS
*/
public shouldRedirectToHttps(domainConfig?: IDomainConfig): boolean {
if (!domainConfig?.forwarding) return false;
// Only check for redirect if HTTP is enabled
if (this.supportsHttp(domainConfig)) {
return !!domainConfig.forwarding.http?.redirectToHttps;
}
return false;
}
}

View File

@ -20,15 +20,5 @@ export { NetworkProxyBridge } from './network-proxy-bridge.js';
export { RouteManager } from './route-manager.js';
export { RouteConnectionHandler } from './route-connection-handler.js';
// Export route helpers for configuration
export {
createRoute,
createHttpRoute,
createHttpsRoute,
createPassthroughRoute,
createRedirectRoute,
createHttpToHttpsRedirect,
createBlockRoute,
createLoadBalancerRoute,
createHttpsServer
} from './route-helpers.js';
// Export all helper functions from the utils directory
export * from './utils/index.js';

View File

@ -33,10 +33,8 @@ export interface ISmartProxyOptions {
// The unified configuration array (required)
routes: IRouteConfig[];
// Port range configuration
globalPortRanges?: Array<{ from: number; to: number }>;
forwardAllGlobalRanges?: boolean;
preserveSourceIP?: boolean;
// Port configuration
preserveSourceIP?: boolean; // Preserve client IP when forwarding
// Global/default settings
defaults?: {
@ -140,6 +138,11 @@ export interface IConnectionRecord {
hasReceivedInitialData: boolean; // Whether initial data has been received
routeConfig?: IRouteConfig; // Associated route config for this connection
// Target information (for dynamic port/host mapping)
targetHost?: string; // Resolved target host
targetPort?: number; // Resolved target port
tlsVersion?: string; // TLS version (for routing context)
// Keep-alive tracking
hasKeepAlive: boolean; // Whether keep-alive is enabled for this connection
inactivityWarningIssued?: boolean; // Whether an inactivity warning has been issued

View File

@ -34,13 +34,43 @@ export interface IRouteMatch {
headers?: Record<string, string | RegExp>; // Match specific HTTP headers
}
/**
* Context provided to port and host mapping functions
*/
export interface IRouteContext {
// Connection information
port: number; // The matched incoming port
domain?: string; // The domain from SNI or Host header
clientIp: string; // The client's IP address
serverIp: string; // The server's IP address
path?: string; // URL path (for HTTP connections)
query?: string; // Query string (for HTTP connections)
headers?: Record<string, string>; // HTTP headers (for HTTP connections)
// TLS information
isTls: boolean; // Whether the connection is TLS
tlsVersion?: string; // TLS version if applicable
// Route information
routeName?: string; // The name of the matched route
routeId?: string; // The ID of the matched route
// Target information (resolved from dynamic mapping)
targetHost?: string; // The resolved target host
targetPort?: number; // The resolved target port
// Additional properties
timestamp: number; // The request timestamp
connectionId: string; // Unique connection identifier
}
/**
* Target configuration for forwarding
*/
export interface IRouteTarget {
host: string | string[]; // Support single host or round-robin
port: number;
preservePort?: boolean; // Use incoming port as target port
host: string | string[] | ((context: any) => string | string[]); // Support static or dynamic host selection with any compatible context
port: number | ((context: any) => number); // Support static or dynamic port mapping with any compatible context
preservePort?: boolean; // Use incoming port as target port (ignored if port is a function)
}
/**
@ -115,6 +145,16 @@ export interface IRouteTestResponse {
body: string;
}
/**
* URL rewriting configuration
*/
export interface IRouteUrlRewrite {
pattern: string; // RegExp pattern to match in URL
target: string; // Replacement pattern (supports template variables like {domain})
flags?: string; // RegExp flags like 'g' for global replacement
onlyRewritePath?: boolean; // Only apply to path, not query string
}
/**
* Advanced options for route actions
*/
@ -124,6 +164,7 @@ export interface IRouteAdvanced {
keepAlive?: boolean;
staticFiles?: IRouteStaticFiles;
testResponse?: IRouteTestResponse;
urlRewrite?: IRouteUrlRewrite; // URL rewriting configuration
// Additional advanced options would go here
}
@ -131,10 +172,15 @@ export interface IRouteAdvanced {
* WebSocket configuration
*/
export interface IRouteWebSocket {
enabled: boolean;
pingInterval?: number;
pingTimeout?: number;
maxPayloadSize?: number;
enabled: boolean; // Whether WebSockets are enabled for this route
pingInterval?: number; // Interval for sending ping frames (ms)
pingTimeout?: number; // Timeout for pong response (ms)
maxPayloadSize?: number; // Maximum message size in bytes
customHeaders?: Record<string, string>; // Custom headers for WebSocket handshake
subprotocols?: string[]; // Supported subprotocols
rewritePath?: string; // Path rewriting for WebSocket connections
allowedOrigins?: string[]; // Allowed origins for WebSocket connections
authenticateRequest?: boolean; // Whether to apply route security to WebSocket connections
}
/**
@ -181,6 +227,12 @@ export interface IRouteAction {
// Advanced options
advanced?: IRouteAdvanced;
// Additional options for backend-specific settings
options?: {
backendProtocol?: 'http1' | 'http2';
[key: string]: any;
};
}
/**
@ -219,12 +271,27 @@ export interface IRouteSecurity {
ipBlockList?: string[];
}
/**
* CORS configuration for a route
*/
export interface IRouteCors {
enabled: boolean; // Whether CORS is enabled for this route
allowOrigin?: string | string[]; // Allowed origins (*,domain.com,[domain1,domain2])
allowMethods?: string; // Allowed methods (GET,POST,etc.)
allowHeaders?: string; // Allowed headers
allowCredentials?: boolean; // Whether to allow credentials
exposeHeaders?: string; // Headers to expose to the client
maxAge?: number; // Preflight cache duration in seconds
preflight?: boolean; // Whether to respond to preflight requests
}
/**
* Headers configuration
*/
export interface IRouteHeaders {
request?: Record<string, string>;
response?: Record<string, string>;
request?: Record<string, string>; // Headers to add/modify for requests to backend
response?: Record<string, string>; // Headers to add/modify for responses to client
cors?: IRouteCors; // CORS configuration
}
/**

View File

@ -1,7 +1,6 @@
import * as plugins from '../../plugins.js';
import { NetworkProxy } from '../network-proxy/index.js';
import { Port80Handler } from '../../http/port80/port80-handler.js';
import { Port80HandlerEvents } from '../../core/models/common-types.js';
import { subscribeToPort80Handler } from '../../core/utils/event-utils.js';
import type { ICertificateData } from '../../certificate/models/certificate-types.js';
import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.js';
@ -11,8 +10,8 @@ import type { IRouteConfig } from './models/route-types.js';
* Manages NetworkProxy integration for TLS termination
*
* NetworkProxyBridge connects SmartProxy with NetworkProxy to handle TLS termination.
* It directly maps route configurations to NetworkProxy configuration format and manages
* certificate provisioning through Port80Handler when ACME is enabled.
* It directly passes route configurations to NetworkProxy and manages the physical
* connection piping between SmartProxy and NetworkProxy for TLS termination.
*
* It is used by SmartProxy for routes that have:
* - TLS mode of 'terminate' or 'terminate-and-reencrypt'
@ -49,7 +48,7 @@ export class NetworkProxyBridge {
*/
public async initialize(): Promise<void> {
if (!this.networkProxy && this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0) {
// Configure NetworkProxy options based on PortProxy settings
// Configure NetworkProxy options based on SmartProxy settings
const networkProxyOptions: any = {
port: this.settings.networkProxyPort!,
portProxyIntegration: true,
@ -57,7 +56,6 @@ export class NetworkProxyBridge {
useExternalPort80Handler: !!this.port80Handler // Use Port80Handler if available
};
this.networkProxy = new NetworkProxy(networkProxyOptions);
console.log(`Initialized NetworkProxy on port ${this.settings.networkProxyPort}`);
@ -80,29 +78,8 @@ export class NetworkProxyBridge {
console.log(`Received certificate for ${data.domain} from Port80Handler, updating NetworkProxy`);
try {
// Find existing config for this domain
const existingConfigs = this.networkProxy.getProxyConfigs()
.filter(config => config.hostName === data.domain);
if (existingConfigs.length > 0) {
// Update existing configs with new certificate
for (const config of existingConfigs) {
config.privateKey = data.privateKey;
config.publicKey = data.certificate;
}
// Apply updated configs
this.networkProxy.updateProxyConfigs(existingConfigs)
.then(() => console.log(`Updated certificate for ${data.domain} in NetworkProxy`))
.catch(err => console.log(`Error updating certificate in NetworkProxy: ${err}`));
} else {
// Create a new config for this domain
console.log(`No existing config found for ${data.domain}, creating new config in NetworkProxy`);
}
} catch (err) {
console.log(`Error handling certificate event: ${err}`);
}
// Apply certificate directly to NetworkProxy
this.networkProxy.updateCertificate(data.domain, data.certificate, data.privateKey);
}
/**
@ -113,7 +90,9 @@ export class NetworkProxyBridge {
console.log(`NetworkProxy not initialized: cannot apply external certificate for ${data.domain}`);
return;
}
this.handleCertificateEvent(data);
// Apply certificate directly to NetworkProxy
this.networkProxy.updateCertificate(data.domain, data.certificate, data.privateKey);
}
/**
@ -155,92 +134,6 @@ export class NetworkProxyBridge {
}
}
/**
* Register domains from routes with Port80Handler for certificate management
*
* Extracts domains from routes that require TLS termination and registers them
* with the Port80Handler for certificate issuance and renewal.
*
* @param routes The route configurations to extract domains from
*/
public registerDomainsWithPort80Handler(routes: IRouteConfig[]): void {
if (!this.port80Handler) {
console.log('Cannot register domains - Port80Handler not initialized');
return;
}
// Extract domains from routes that require TLS termination
const domainsToRegister = new Set<string>();
for (const route of routes) {
// Skip routes without domains or TLS configuration
if (!route.match.domains || !route.action.tls) continue;
// Only register domains for routes that terminate TLS
if (route.action.tls.mode !== 'terminate' && route.action.tls.mode !== 'terminate-and-reencrypt') continue;
// Extract domains from route
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
// Add each domain to the set (avoiding duplicates)
for (const domain of domains) {
// Skip wildcards
if (domain.includes('*')) {
console.log(`Skipping wildcard domain for ACME: ${domain}`);
continue;
}
domainsToRegister.add(domain);
}
}
// Register each unique domain with Port80Handler
for (const domain of domainsToRegister) {
try {
this.port80Handler.addDomain({
domainName: domain,
sslRedirect: true,
acmeMaintenance: true,
// Include route reference if we can find it
routeReference: this.findRouteReferenceForDomain(domain, routes)
});
console.log(`Registered domain with Port80Handler: ${domain}`);
} catch (err) {
console.log(`Error registering domain ${domain} with Port80Handler: ${err}`);
}
}
}
/**
* Finds the route reference for a given domain
*
* @param domain The domain to find a route reference for
* @param routes The routes to search
* @returns The route reference if found, undefined otherwise
*/
private findRouteReferenceForDomain(domain: string, routes: IRouteConfig[]): { routeId?: string; routeName?: string } | undefined {
// Find the first route that matches this domain
for (const route of routes) {
if (!route.match.domains) continue;
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
if (domains.includes(domain)) {
return {
routeId: undefined, // No explicit IDs in our current routes
routeName: route.name
};
}
}
return undefined;
}
/**
* Forwards a TLS connection to a NetworkProxy for handling
*/
@ -305,7 +198,6 @@ export class NetworkProxyBridge {
socket.pipe(proxySocket);
proxySocket.pipe(socket);
// Update activity on data transfer (caller should handle this)
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] TLS connection successfully forwarded to NetworkProxy`);
}
@ -315,13 +207,8 @@ export class NetworkProxyBridge {
/**
* Synchronizes routes to NetworkProxy
*
* This method directly maps route configurations to NetworkProxy format and updates
* the NetworkProxy with these configurations. It handles:
*
* - Extracting domain, target, and certificate information from routes
* - Converting TLS mode settings to NetworkProxy configuration
* - Applying security and advanced settings
* - Registering domains for ACME certificate provisioning when needed
* This method directly passes route configurations to NetworkProxy without any
* intermediate conversion. NetworkProxy natively understands route configurations.
*
* @param routes The route configurations to sync to NetworkProxy
*/
@ -332,140 +219,22 @@ export class NetworkProxyBridge {
}
try {
// Get SSL certificates from assets
// Import fs directly since it's not in plugins
const fs = await import('fs');
let defaultCertPair;
try {
defaultCertPair = {
key: fs.readFileSync('assets/certs/key.pem', 'utf8'),
cert: fs.readFileSync('assets/certs/cert.pem', 'utf8'),
};
} catch (certError) {
console.log(`Warning: Could not read default certificates: ${certError}`);
console.log(
'Using empty certificate placeholders - ACME will generate proper certificates if enabled'
// Filter only routes that are applicable to NetworkProxy (TLS termination)
const networkProxyRoutes = routes.filter(route => {
return (
route.action.type === 'forward' &&
route.action.tls &&
(route.action.tls.mode === 'terminate' || route.action.tls.mode === 'terminate-and-reencrypt')
);
});
// Use empty placeholders - NetworkProxy will use its internal defaults
// or ACME will generate proper ones if enabled
defaultCertPair = {
key: '',
cert: '',
};
}
// Map routes directly to NetworkProxy configs
const proxyConfigs = this.mapRoutesToNetworkProxyConfigs(routes, defaultCertPair);
// Update the proxy configs
await this.networkProxy.updateProxyConfigs(proxyConfigs);
console.log(`Synced ${proxyConfigs.length} configurations to NetworkProxy`);
// Register domains with Port80Handler for certificate issuance
if (this.port80Handler) {
this.registerDomainsWithPort80Handler(routes);
}
// Pass routes directly to NetworkProxy
await this.networkProxy.updateRouteConfigs(networkProxyRoutes);
console.log(`Synced ${networkProxyRoutes.length} routes directly to NetworkProxy`);
} catch (err) {
console.log(`Error syncing routes to NetworkProxy: ${err}`);
}
}
/**
* Map routes directly to NetworkProxy configuration format
*
* This method directly maps route configurations to NetworkProxy's format
* without any intermediate domain-based representation. It processes each route
* and creates appropriate NetworkProxy configs for domains that require TLS termination.
*
* @param routes Array of route configurations to map
* @param defaultCertPair Default certificate to use if no custom certificate is specified
* @returns Array of NetworkProxy configurations
*/
public mapRoutesToNetworkProxyConfigs(
routes: IRouteConfig[],
defaultCertPair: { key: string; cert: string }
): plugins.tsclass.network.IReverseProxyConfig[] {
const configs: plugins.tsclass.network.IReverseProxyConfig[] = [];
for (const route of routes) {
// Skip routes without domains
if (!route.match.domains) continue;
// Skip non-forward routes
if (route.action.type !== 'forward') continue;
// Skip routes without TLS configuration
if (!route.action.tls || !route.action.target) continue;
// Skip routes that don't require TLS termination
if (route.action.tls.mode !== 'terminate' && route.action.tls.mode !== 'terminate-and-reencrypt') continue;
// Get domains from route
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
// Create a config for each domain
for (const domain of domains) {
// Get certificate
let certKey = defaultCertPair.key;
let certCert = defaultCertPair.cert;
// Use custom certificate if specified
if (route.action.tls.certificate !== 'auto' && typeof route.action.tls.certificate === 'object') {
certKey = route.action.tls.certificate.key;
certCert = route.action.tls.certificate.cert;
}
// Determine target hosts and ports
const targetHosts = Array.isArray(route.action.target.host)
? route.action.target.host
: [route.action.target.host];
const targetPort = route.action.target.port;
// Create the NetworkProxy config
const config: plugins.tsclass.network.IReverseProxyConfig = {
hostName: domain,
privateKey: certKey,
publicKey: certCert,
destinationIps: targetHosts,
destinationPorts: [targetPort]
// Note: We can't include additional metadata as it's not supported in the interface
};
configs.push(config);
}
}
return configs;
}
/**
* @deprecated This method is kept for backward compatibility.
* Use mapRoutesToNetworkProxyConfigs() instead.
*/
public convertRoutesToNetworkProxyConfigs(
routes: IRouteConfig[],
defaultCertPair: { key: string; cert: string }
): plugins.tsclass.network.IReverseProxyConfig[] {
return this.mapRoutesToNetworkProxyConfigs(routes, defaultCertPair);
}
/**
* @deprecated This method is deprecated and will be removed in a future version.
* Use syncRoutesToNetworkProxy() instead.
*
* This legacy method exists only for backward compatibility and
* simply forwards to syncRoutesToNetworkProxy().
*/
public async syncDomainConfigsToNetworkProxy(): Promise<void> {
console.log('DEPRECATED: Method syncDomainConfigsToNetworkProxy will be removed in a future version.');
console.log('Please use syncRoutesToNetworkProxy() instead for direct route-based configuration.');
await this.syncRoutesToNetworkProxy(this.settings.routes || []);
}
/**
* Request a certificate for a specific domain
@ -496,12 +265,6 @@ export class NetworkProxyBridge {
domainOptions.routeReference = {
routeName
};
} else {
// Try to find a route reference from the current routes
const routeReference = this.findRouteReferenceForDomain(domain, this.settings.routes || []);
if (routeReference) {
domainOptions.routeReference = routeReference;
}
}
// Register the domain for certificate issuance

View File

@ -0,0 +1,195 @@
import * as plugins from '../../plugins.js';
import type { ISmartProxyOptions } from './models/interfaces.js';
import { RouteConnectionHandler } from './route-connection-handler.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.
*/
export class PortManager {
private servers: Map<number, plugins.net.Server> = new Map();
private settings: ISmartProxyOptions;
private routeConnectionHandler: RouteConnectionHandler;
private isShuttingDown: boolean = false;
/**
* 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)) {
console.log(`PortManager: Already listening on port ${port}`);
return;
}
// 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) => {
console.log(`Server Error on port ${port}: ${err.message}`);
});
// Start listening on the port
return new Promise<void>((resolve, reject) => {
server.listen(port, () => {
const isNetworkProxyPort = this.settings.useNetworkProxy?.includes(port);
console.log(
`SmartProxy -> OK: Now listening on port ${port}${
isNetworkProxyPort ? ' (NetworkProxy forwarding enabled)' : ''
}`
);
// Store the server reference
this.servers.set(port, server);
resolve();
}).on('error', (err) => {
console.log(`Failed to listen on port ${port}: ${err.message}`);
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> {
// Get the server for this port
const server = this.servers.get(port);
if (!server) {
console.log(`PortManager: Not listening on port ${port}`);
return;
}
// Close the server
return new Promise<void>((resolve) => {
server.close((err) => {
if (err) {
console.log(`Error closing server on port ${port}: ${err.message}`);
} else {
console.log(`SmartProxy -> Stopped listening on port ${port}`);
}
// Remove the server reference
this.servers.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);
}
}

View File

@ -8,7 +8,8 @@ import {
} from './models/interfaces.js';
import type {
IRouteConfig,
IRouteAction
IRouteAction,
IRouteContext
} from './models/route-types.js';
import { ConnectionManager } from './connection-manager.js';
import { SecurityManager } from './security-manager.js';
@ -24,6 +25,9 @@ import type { ForwardingHandler } from '../../forwarding/handlers/base-handler.j
export class RouteConnectionHandler {
private settings: ISmartProxyOptions;
// Cache for route contexts to avoid recreation
private routeContextCache: Map<string, IRouteContext> = new Map();
constructor(
settings: ISmartProxyOptions,
private connectionManager: ConnectionManager,
@ -36,6 +40,47 @@ export class RouteConnectionHandler {
this.settings = settings;
}
/**
* Create a route context object for port and host mapping functions
*/
private createRouteContext(options: {
connectionId: string;
port: number;
domain?: string;
clientIp: string;
serverIp: string;
isTls: boolean;
tlsVersion?: string;
routeName?: string;
routeId?: string;
path?: string;
query?: string;
headers?: Record<string, string>;
}): IRouteContext {
return {
// Connection information
port: options.port,
domain: options.domain,
clientIp: options.clientIp,
serverIp: options.serverIp,
path: options.path,
query: options.query,
headers: options.headers,
// TLS information
isTls: options.isTls,
tlsVersion: options.tlsVersion,
// Route information
routeName: options.routeName,
routeId: options.routeId,
// Additional properties
timestamp: Date.now(),
connectionId: options.connectionId
};
}
/**
* Handle a new incoming connection
*/
@ -325,7 +370,7 @@ export class RouteConnectionHandler {
): void {
const connectionId = record.id;
const action = route.action;
// We should have a target configuration for forwarding
if (!action.target) {
console.log(`[${connectionId}] Forward action missing target configuration`);
@ -333,24 +378,82 @@ export class RouteConnectionHandler {
this.connectionManager.cleanupConnection(record, 'missing_target');
return;
}
// Create the routing context for this connection
const routeContext = this.createRouteContext({
connectionId: record.id,
port: record.localPort,
domain: record.lockedDomain,
clientIp: record.remoteIP,
serverIp: socket.localAddress || '',
isTls: record.isTLS || false,
tlsVersion: record.tlsVersion,
routeName: route.name,
routeId: route.id
});
// Cache the context for potential reuse
this.routeContextCache.set(connectionId, routeContext);
// Determine host using function or static value
let targetHost: string | string[];
if (typeof action.target.host === 'function') {
try {
targetHost = action.target.host(routeContext);
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Dynamic host resolved to: ${Array.isArray(targetHost) ? targetHost.join(', ') : targetHost}`);
}
} catch (err) {
console.log(`[${connectionId}] Error in host mapping function: ${err}`);
socket.end();
this.connectionManager.cleanupConnection(record, 'host_mapping_error');
return;
}
} else {
targetHost = action.target.host;
}
// If an array of hosts, select one randomly for load balancing
const selectedHost = Array.isArray(targetHost)
? targetHost[Math.floor(Math.random() * targetHost.length)]
: targetHost;
// Determine port using function or static value
let targetPort: number;
if (typeof action.target.port === 'function') {
try {
targetPort = action.target.port(routeContext);
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Dynamic port mapping: ${record.localPort} -> ${targetPort}`);
}
// Store the resolved target port in the context for potential future use
routeContext.targetPort = targetPort;
} catch (err) {
console.log(`[${connectionId}] Error in port mapping function: ${err}`);
socket.end();
this.connectionManager.cleanupConnection(record, 'port_mapping_error');
return;
}
} else if (action.target.preservePort) {
// Use incoming port if preservePort is true
targetPort = record.localPort;
} else {
// Use static port from configuration
targetPort = action.target.port;
}
// Store the resolved host in the context
routeContext.targetHost = selectedHost;
// Determine if this needs TLS handling
if (action.tls) {
switch (action.tls.mode) {
case 'passthrough':
// For TLS passthrough, just forward directly
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Using TLS passthrough to ${action.target.host}`);
console.log(`[${connectionId}] Using TLS passthrough to ${selectedHost}:${targetPort}`);
}
// Allow for array of hosts
const targetHost = Array.isArray(action.target.host)
? action.target.host[Math.floor(Math.random() * action.target.host.length)]
: action.target.host;
// Determine target port - either target port or preserve incoming port
const targetPort = action.target.preservePort ? record.localPort : action.target.port;
return this.setupDirectConnection(
socket,
record,
@ -358,7 +461,7 @@ export class RouteConnectionHandler {
record.lockedDomain,
initialChunk,
undefined,
targetHost,
selectedHost,
targetPort
);
@ -402,14 +505,36 @@ export class RouteConnectionHandler {
console.log(`[${connectionId}] Using basic forwarding to ${action.target.host}:${action.target.port}`);
}
// Allow for array of hosts
const targetHost = Array.isArray(action.target.host)
? action.target.host[Math.floor(Math.random() * action.target.host.length)]
: action.target.host;
// Determine target port - either target port or preserve incoming port
const targetPort = action.target.preservePort ? record.localPort : action.target.port;
// Get the appropriate host value
let targetHost: string;
if (typeof action.target.host === 'function') {
// For function-based host, use the same routeContext created earlier
const hostResult = action.target.host(routeContext);
targetHost = Array.isArray(hostResult)
? hostResult[Math.floor(Math.random() * hostResult.length)]
: hostResult;
} else {
// For static host value
targetHost = Array.isArray(action.target.host)
? action.target.host[Math.floor(Math.random() * action.target.host.length)]
: action.target.host;
}
// Determine port - either function-based, static, or preserve incoming port
let targetPort: number;
if (typeof action.target.port === 'function') {
targetPort = action.target.port(routeContext);
} else if (action.target.preservePort) {
targetPort = record.localPort;
} else {
targetPort = action.target.port;
}
// Update the connection record and context with resolved values
record.targetHost = targetHost;
record.targetPort = targetPort;
return this.setupDirectConnection(
socket,
record,
@ -552,13 +677,23 @@ export class RouteConnectionHandler {
// Determine target host and port if not provided
const finalTargetHost = targetHost ||
record.targetHost ||
(this.settings.defaults?.target?.host || 'localhost');
// Determine target port
const finalTargetPort = targetPort ||
record.targetPort ||
(overridePort !== undefined ? overridePort :
(this.settings.defaults?.target?.port || 443));
// Update record with final target information
record.targetHost = finalTargetHost;
record.targetPort = finalTargetPort;
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Setting up direct connection to ${finalTargetHost}:${finalTargetPort}`);
}
// Setup connection options
const connectionOptions: plugins.net.NetConnectOpts = {
host: finalTargetHost,

View File

@ -58,36 +58,88 @@ export class RouteManager extends plugins.EventEmitter {
/**
* Rebuild the port mapping for fast lookups
* Also logs information about the ports being listened on
*/
private rebuildPortMap(): void {
this.portMap.clear();
this.portRangeCache.clear(); // Clear cache when rebuilding
// Track ports for logging
const portToRoutesMap = new Map<number, string[]>();
for (const route of this.routes) {
const ports = this.expandPortRange(route.match.ports);
// Skip if no ports were found
if (ports.length === 0) {
console.warn(`Route ${route.name || 'unnamed'} has no valid ports to listen on`);
continue;
}
for (const port of ports) {
// Add to portMap for routing
if (!this.portMap.has(port)) {
this.portMap.set(port, []);
}
this.portMap.get(port)!.push(route);
// Add to tracking for logging
if (!portToRoutesMap.has(port)) {
portToRoutesMap.set(port, []);
}
portToRoutesMap.get(port)!.push(route.name || 'unnamed');
}
}
// Log summary of ports and routes
const totalPorts = this.portMap.size;
const totalRoutes = this.routes.length;
console.log(`Route manager configured with ${totalRoutes} routes across ${totalPorts} ports`);
// Log port details if detailed logging is enabled
const enableDetailedLogging = this.options.enableDetailedLogging;
if (enableDetailedLogging) {
for (const [port, routes] of this.portMap.entries()) {
console.log(`Port ${port}: ${routes.length} routes (${portToRoutesMap.get(port)!.join(', ')})`);
}
}
}
/**
* Expand a port range specification into an array of individual ports
* Uses caching to improve performance for frequently used port ranges
*
* @public - Made public to allow external code to interpret port ranges
*/
private expandPortRange(portRange: TPortRange): number[] {
public expandPortRange(portRange: TPortRange): number[] {
// For simple number, return immediately
if (typeof portRange === 'number') {
return [portRange];
}
// Create a cache key for this port range
const cacheKey = JSON.stringify(portRange);
// Check if we have a cached result
if (this.portRangeCache.has(cacheKey)) {
return this.portRangeCache.get(cacheKey)!;
}
// Process the port range
let result: number[] = [];
if (Array.isArray(portRange)) {
// Handle array of port objects or numbers
return portRange.flatMap(item => {
result = portRange.flatMap(item => {
if (typeof item === 'number') {
return [item];
} else if (typeof item === 'object' && 'from' in item && 'to' in item) {
// Handle port range object - check valid range
if (item.from > item.to) {
console.warn(`Invalid port range: from (${item.from}) > to (${item.to})`);
return [];
}
// Handle port range object
const ports: number[] = [];
for (let p = item.from; p <= item.to; p++) {
@ -98,14 +150,24 @@ export class RouteManager extends plugins.EventEmitter {
return [];
});
}
return [];
// Cache the result
this.portRangeCache.set(cacheKey, result);
return result;
}
/**
* Memoization cache for expanded port ranges
*/
private portRangeCache: Map<string, number[]> = new Map();
/**
* Get all ports that should be listened on
* This method automatically infers all required ports from route configurations
*/
public getListeningPorts(): number[] {
// Return the unique set of ports from all routes
return Array.from(this.portMap.keys());
}

View File

@ -6,7 +6,7 @@ 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 { PortManager } from './port-manager.js';
import { RouteManager } from './route-manager.js';
import { RouteConnectionHandler } from './route-connection-handler.js';
@ -39,7 +39,8 @@ import type { IRouteConfig } from './models/route-types.js';
* - Advanced options (timeout, headers, etc.)
*/
export class SmartProxy extends plugins.EventEmitter {
private netServers: plugins.net.Server[] = [];
// Port manager handles dynamic listener management
private portManager: PortManager;
private connectionLogger: NodeJS.Timeout | null = null;
private isShuttingDown: boolean = false;
@ -49,8 +50,7 @@ export class SmartProxy extends plugins.EventEmitter {
private tlsManager: TlsManager;
private networkProxyBridge: NetworkProxyBridge;
private timeoutManager: TimeoutManager;
// private portRangeManager: PortRangeManager;
private routeManager: RouteManager;
public routeManager: RouteManager; // Made public for route management
private routeConnectionHandler: RouteConnectionHandler;
// Port80Handler for ACME certificate management
@ -151,8 +151,6 @@ export class SmartProxy extends plugins.EventEmitter {
// 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);
@ -168,6 +166,9 @@ export class SmartProxy extends plugins.EventEmitter {
this.timeoutManager,
this.routeManager
);
// Initialize port manager
this.portManager = new PortManager(this.settings, this.routeConnectionHandler);
}
/**
@ -271,33 +272,8 @@ export class SmartProxy extends plugins.EventEmitter {
// 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);
}
// Start port listeners using the PortManager
await this.portManager.addPorts(listeningPorts);
// Set up periodic connection logging and inactivity checks
this.connectionLogger = setInterval(() => {
@ -383,6 +359,7 @@ export class SmartProxy extends plugins.EventEmitter {
public async stop() {
console.log('SmartProxy shutting down...');
this.isShuttingDown = true;
this.portManager.setShuttingDown(true);
// Stop CertProvisioner if active
if (this.certProvisioner) {
@ -401,31 +378,14 @@ export class SmartProxy extends plugins.EventEmitter {
}
}
// Stop accepting new connections
const closeServerPromises: Promise<void>[] = this.netServers.map(
(server) =>
new Promise<void>((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);
// Stop all port listeners
await this.portManager.closeAll();
console.log('All servers closed. Cleaning up active connections...');
// Clean up all active connections
@ -434,8 +394,6 @@ export class SmartProxy extends plugins.EventEmitter {
// Stop NetworkProxy
await this.networkProxyBridge.stop();
// Clear all servers
this.netServers = [];
console.log('SmartProxy shutdown complete.');
}
@ -479,6 +437,12 @@ export class SmartProxy extends plugins.EventEmitter {
// Update routes in RouteManager
this.routeManager.updateRoutes(newRoutes);
// Get the new set of required ports
const requiredPorts = this.routeManager.getListeningPorts();
// Update port listeners to match the new configuration
await this.portManager.updatePorts(requiredPorts);
// If NetworkProxy is initialized, resync the configurations
if (this.networkProxyBridge.getNetworkProxy()) {
await this.networkProxyBridge.syncRoutesToNetworkProxy(newRoutes);
@ -609,6 +573,41 @@ export class SmartProxy extends plugins.EventEmitter {
return true;
}
/**
* Add a new listening port without changing the route configuration
*
* This allows you to add a port listener without updating routes.
* Useful for preparing to listen on a port before adding routes for it.
*
* @param port The port to start listening on
* @returns Promise that resolves when the port is listening
*/
public async addListeningPort(port: number): Promise<void> {
return this.portManager.addPort(port);
}
/**
* Stop listening on a specific port without changing the route configuration
*
* This allows you to stop a port listener without updating routes.
* Useful for temporary maintenance or port changes.
*
* @param port The port to stop listening on
* @returns Promise that resolves when the port is closed
*/
public async removeListeningPort(port: number): Promise<void> {
return this.portManager.removePort(port);
}
/**
* Get a list of all ports currently being listened on
*
* @returns Array of port numbers
*/
public getListeningPorts(): number[] {
return this.portManager.getListeningPorts();
}
/**
* Get statistics about current connections
*/
@ -638,7 +637,9 @@ export class SmartProxy extends plugins.EventEmitter {
terminationStats,
acmeEnabled: !!this.port80Handler,
port80HandlerPort: this.port80Handler ? this.settings.acme?.port : null,
routes: this.routeManager.getListeningPorts().length
routes: this.routeManager.getListeningPorts().length,
listeningPorts: this.portManager.getListeningPorts(),
activePorts: this.portManager.getListeningPorts().length
};
}

View File

@ -14,9 +14,11 @@
* - Static file server routes (createStaticFileRoute)
* - API routes (createApiRoute)
* - WebSocket routes (createWebSocketRoute)
* - Port mapping routes (createPortMappingRoute, createOffsetPortMappingRoute)
* - Dynamic routing (createDynamicRoute, createSmartLoadBalancer)
*/
import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTarget, TPortRange } from '../models/route-types.js';
import type { IRouteConfig, IRouteMatch, IRouteAction, IRouteTarget, TPortRange, IRouteContext } from '../models/route-types.js';
/**
* Create an HTTP-only route configuration
@ -452,4 +454,168 @@ export function createWebSocketRoute(
priority: options.priority || 100, // Higher priority for WebSocket routes
...options
};
}
/**
* Create a helper function that applies a port offset
* @param offset The offset to apply to the matched port
* @returns A function that adds the offset to the matched port
*/
export function createPortOffset(offset: number): (context: IRouteContext) => number {
return (context: IRouteContext) => context.port + offset;
}
/**
* Create a port mapping route with context-based port function
* @param options Port mapping route options
* @returns Route configuration object
*/
export function createPortMappingRoute(options: {
sourcePortRange: TPortRange;
targetHost: string | string[] | ((context: IRouteContext) => string | string[]);
portMapper: (context: IRouteContext) => number;
name?: string;
domains?: string | string[];
priority?: number;
[key: string]: any;
}): IRouteConfig {
// Create route match
const match: IRouteMatch = {
ports: options.sourcePortRange,
domains: options.domains
};
// Create route action
const action: IRouteAction = {
type: 'forward',
target: {
host: options.targetHost,
port: options.portMapper
}
};
// Create the route config
return {
match,
action,
name: options.name || `Port Mapping Route for ${options.domains || 'all domains'}`,
priority: options.priority,
...options
};
}
/**
* Create a simple offset port mapping route
* @param options Offset port mapping route options
* @returns Route configuration object
*/
export function createOffsetPortMappingRoute(options: {
ports: TPortRange;
targetHost: string | string[];
offset: number;
name?: string;
domains?: string | string[];
priority?: number;
[key: string]: any;
}): IRouteConfig {
return createPortMappingRoute({
sourcePortRange: options.ports,
targetHost: options.targetHost,
portMapper: (context) => context.port + options.offset,
name: options.name || `Offset Mapping (${options.offset > 0 ? '+' : ''}${options.offset}) for ${options.domains || 'all domains'}`,
domains: options.domains,
priority: options.priority,
...options
});
}
/**
* Create a dynamic route with context-based host and port mapping
* @param options Dynamic route options
* @returns Route configuration object
*/
export function createDynamicRoute(options: {
ports: TPortRange;
targetHost: (context: IRouteContext) => string | string[];
portMapper: (context: IRouteContext) => number;
name?: string;
domains?: string | string[];
path?: string;
clientIp?: string[];
priority?: number;
[key: string]: any;
}): IRouteConfig {
// Create route match
const match: IRouteMatch = {
ports: options.ports,
domains: options.domains,
path: options.path,
clientIp: options.clientIp
};
// Create route action
const action: IRouteAction = {
type: 'forward',
target: {
host: options.targetHost,
port: options.portMapper
}
};
// Create the route config
return {
match,
action,
name: options.name || `Dynamic Route for ${options.domains || 'all domains'}`,
priority: options.priority,
...options
};
}
/**
* Create a smart load balancer with dynamic domain-based backend selection
* @param options Smart load balancer options
* @returns Route configuration object
*/
export function createSmartLoadBalancer(options: {
ports: TPortRange;
domainTargets: Record<string, string | string[]>;
portMapper: (context: IRouteContext) => number;
name?: string;
defaultTarget?: string | string[];
priority?: number;
[key: string]: any;
}): IRouteConfig {
// Extract all domain keys to create the match criteria
const domains = Object.keys(options.domainTargets);
// Create the smart host selector function
const hostSelector = (context: IRouteContext) => {
const domain = context.domain || '';
return options.domainTargets[domain] || options.defaultTarget || 'localhost';
};
// Create route match
const match: IRouteMatch = {
ports: options.ports,
domains
};
// Create route action
const action: IRouteAction = {
type: 'forward',
target: {
host: hostSelector,
port: options.portMapper
}
};
// Create the route config
return {
match,
action,
name: options.name || `Smart Load Balancer for ${domains.join(', ')}`,
priority: options.priority,
...options
};
}

View File

@ -9,14 +9,24 @@ import type { IRouteConfig, IRouteMatch, IRouteAction, TPortRange } from '../mod
/**
* Validates a port range or port number
* @param port Port number or port range
* @param port Port number, port range, or port function
* @returns True if valid, false otherwise
*/
export function isValidPort(port: TPortRange): boolean {
export function isValidPort(port: any): boolean {
if (typeof port === 'number') {
return port > 0 && port < 65536; // Valid port range is 1-65535
} else if (Array.isArray(port)) {
return port.every(p => typeof p === 'number' && p > 0 && p < 65536);
return port.every(p =>
(typeof p === 'number' && p > 0 && p < 65536) ||
(typeof p === 'object' && 'from' in p && 'to' in p &&
p.from > 0 && p.from < 65536 && p.to > 0 && p.to < 65536)
);
} else if (typeof port === 'function') {
// For function-based ports, we can't validate the result at config time
// so we just check that it's a function
return true;
} else if (typeof port === 'object' && 'from' in port && 'to' in port) {
return port.from > 0 && port.from < 65536 && port.to > 0 && port.to < 65536;
}
return false;
}
@ -100,11 +110,20 @@ export function validateRouteAction(action: IRouteAction): { valid: boolean; err
// Validate target host
if (!action.target.host) {
errors.push('Target host is required');
} else if (typeof action.target.host !== 'string' &&
!Array.isArray(action.target.host) &&
typeof action.target.host !== 'function') {
errors.push('Target host must be a string, array of strings, or function');
}
// Validate target port
if (!action.target.port || !isValidPort(action.target.port)) {
errors.push('Valid target port is required');
if (action.target.port === undefined) {
errors.push('Target port is required');
} else if (typeof action.target.port !== 'number' &&
typeof action.target.port !== 'function') {
errors.push('Target port must be a number or a function');
} else if (typeof action.target.port === 'number' && !isValidPort(action.target.port)) {
errors.push('Target port must be between 1 and 65535');
}
}