2025-05-09 21:21:28 +00:00
import * as plugins from '../../plugins.js' ;
2025-05-19 23:37:11 +00:00
import { logger } from '../../core/utils/logger.js' ;
2025-05-09 21:21:28 +00:00
2025-05-10 00:01:02 +00:00
// Importing required components
2025-05-09 21:21:28 +00:00
import { ConnectionManager } from './connection-manager.js' ;
import { SecurityManager } from './security-manager.js' ;
import { TlsManager } from './tls-manager.js' ;
2025-05-19 17:28:05 +00:00
import { HttpProxyBridge } from './http-proxy-bridge.js' ;
2025-05-09 21:21:28 +00:00
import { TimeoutManager } from './timeout-manager.js' ;
2025-05-13 12:48:41 +00:00
import { PortManager } from './port-manager.js' ;
2025-05-10 00:01:02 +00:00
import { RouteManager } from './route-manager.js' ;
import { RouteConnectionHandler } from './route-connection-handler.js' ;
2025-05-15 14:35:01 +00:00
import { NFTablesManager } from './nftables-manager.js' ;
2025-05-09 21:21:28 +00:00
2025-05-18 15:38:07 +00:00
// Certificate manager
import { SmartCertManager , type ICertStatus } from './certificate-manager.js' ;
2025-05-09 21:21:28 +00:00
2025-05-10 00:01:02 +00:00
// Import types and utilities
2025-05-10 00:49:39 +00:00
import type {
2025-05-15 08:56:27 +00:00
ISmartProxyOptions
2025-05-10 00:01:02 +00:00
} from './models/interfaces.js' ;
import type { IRouteConfig } from './models/route-types.js' ;
2025-05-04 12:44:35 +00:00
2025-05-19 03:40:58 +00:00
// Import mutex for route update synchronization
import { Mutex } from './utils/mutex.js' ;
// Import ACME state manager
import { AcmeStateManager } from './acme-state-manager.js' ;
2025-03-25 22:30:57 +00:00
/ * *
2025-05-10 00:49:39 +00:00
* 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 . )
2025-03-25 22:30:57 +00:00
* /
2025-05-01 12:13:18 +00:00
export class SmartProxy extends plugins . EventEmitter {
2025-05-13 12:48:41 +00:00
// Port manager handles dynamic listener management
private portManager : PortManager ;
2025-03-25 22:30:57 +00:00
private connectionLogger : NodeJS.Timeout | null = null ;
private isShuttingDown : boolean = false ;
// Component managers
private connectionManager : ConnectionManager ;
private securityManager : SecurityManager ;
private tlsManager : TlsManager ;
2025-05-19 17:28:05 +00:00
private httpProxyBridge : HttpProxyBridge ;
2025-03-25 22:30:57 +00:00
private timeoutManager : TimeoutManager ;
2025-05-13 12:48:41 +00:00
public routeManager : RouteManager ; // Made public for route management
2025-05-10 00:01:02 +00:00
private routeConnectionHandler : RouteConnectionHandler ;
2025-05-15 14:35:01 +00:00
private nftablesManager : NFTablesManager ;
2025-03-25 22:30:57 +00:00
2025-05-18 15:38:07 +00:00
// Certificate manager for ACME and static certificates
private certManager : SmartCertManager | null = null ;
2025-03-25 22:30:57 +00:00
2025-05-19 03:40:58 +00:00
// Global challenge route tracking
private globalChallengeRouteActive : boolean = false ;
private routeUpdateLock : any = null ; // Will be initialized as AsyncMutex
private acmeStateManager : AcmeStateManager ;
2025-05-20 15:32:19 +00:00
// Track port usage across route updates
private portUsageMap : Map < number , Set < string > > = new Map ( ) ;
2025-05-10 00:01:02 +00:00
/ * *
2025-05-10 00:49:39 +00:00
* 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 } ,
2025-05-15 14:35:01 +00:00
* security : { ipAllowList : [ '*' ] }
2025-05-10 00:49:39 +00:00
* }
* } ) ;
* ` ` `
2025-05-10 00:01:02 +00:00
* /
2025-05-09 22:46:53 +00:00
constructor ( settingsArg : ISmartProxyOptions ) {
2025-05-01 12:13:18 +00:00
super ( ) ;
2025-05-10 00:01:02 +00:00
2025-03-25 22:30:57 +00:00
// 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 ,
2025-05-09 22:46:53 +00:00
enableKeepAliveProbes :
2025-03-25 22:30:57 +00:00
settingsArg . enableKeepAliveProbes !== undefined ? settingsArg.enableKeepAliveProbes : true ,
enableDetailedLogging : settingsArg.enableDetailedLogging || false ,
enableTlsDebugLogging : settingsArg.enableTlsDebugLogging || false ,
enableRandomizedTimeouts : settingsArg.enableRandomizedTimeouts || false ,
2025-05-09 22:46:53 +00:00
allowSessionTicket :
2025-03-25 22:30:57 +00:00
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 ,
2025-05-19 17:28:05 +00:00
httpProxyPort : settingsArg.httpProxyPort || 8443 ,
2025-03-25 22:30:57 +00:00
} ;
2025-05-18 18:29:59 +00:00
// Normalize ACME options if provided (support both email and accountEmail)
if ( this . settings . acme ) {
// Support both 'email' and 'accountEmail' fields
if ( this . settings . acme . accountEmail && ! this . settings . acme . email ) {
this . settings . acme . email = this . settings . acme . accountEmail ;
}
// Set reasonable defaults for commonly used fields
2025-05-02 14:58:33 +00:00
this . settings . acme = {
2025-05-18 18:29:59 +00:00
enabled : this.settings.acme.enabled !== false , // Enable by default if acme object exists
port : this.settings.acme.port || 80 ,
email : this.settings.acme.email ,
useProduction : this.settings.acme.useProduction || false ,
renewThresholdDays : this.settings.acme.renewThresholdDays || 30 ,
autoRenew : this.settings.acme.autoRenew !== false , // Enable by default
certificateStore : this.settings.acme.certificateStore || './certs' ,
skipConfiguredCerts : this.settings.acme.skipConfiguredCerts || false ,
renewCheckIntervalHours : this.settings.acme.renewCheckIntervalHours || 24 ,
routeForwards : this.settings.acme.routeForwards || [ ] ,
. . . this . settings . acme // Preserve any additional fields
2025-05-02 11:19:14 +00:00
} ;
2025-03-25 22:30:57 +00:00
}
// 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
) ;
2025-05-10 00:01:02 +00:00
2025-05-10 00:49:39 +00:00
// Create the route manager
2025-05-10 00:26:03 +00:00
this . routeManager = new RouteManager ( this . settings ) ;
2025-05-10 00:01:02 +00:00
// Create other required components
2025-03-25 22:30:57 +00:00
this . tlsManager = new TlsManager ( this . settings ) ;
2025-05-19 17:28:05 +00:00
this . httpProxyBridge = new HttpProxyBridge ( this . settings ) ;
2025-03-25 22:30:57 +00:00
2025-05-10 00:01:02 +00:00
// Initialize connection handler with route support
this . routeConnectionHandler = new RouteConnectionHandler (
2025-03-25 22:30:57 +00:00
this . settings ,
this . connectionManager ,
this . securityManager ,
this . tlsManager ,
2025-05-19 17:28:05 +00:00
this . httpProxyBridge ,
2025-03-25 22:30:57 +00:00
this . timeoutManager ,
2025-05-10 00:01:02 +00:00
this . routeManager
2025-03-25 22:30:57 +00:00
) ;
2025-05-13 12:48:41 +00:00
// Initialize port manager
this . portManager = new PortManager ( this . settings , this . routeConnectionHandler ) ;
2025-05-15 14:35:01 +00:00
// Initialize NFTablesManager
this . nftablesManager = new NFTablesManager ( this . settings ) ;
2025-05-19 03:40:58 +00:00
// Initialize route update mutex for synchronization
this . routeUpdateLock = new Mutex ( ) ;
// Initialize ACME state manager
this . acmeStateManager = new AcmeStateManager ( ) ;
2025-03-25 22:30:57 +00:00
}
/ * *
2025-05-10 00:01:02 +00:00
* The settings for the SmartProxy
2025-03-25 22:30:57 +00:00
* /
2025-05-09 22:46:53 +00:00
public settings : ISmartProxyOptions ;
2025-03-25 22:30:57 +00:00
2025-05-18 23:07:31 +00:00
/ * *
* Helper method to create and configure certificate manager
* This ensures consistent setup including the required ACME callback
* /
private async createCertificateManager (
routes : IRouteConfig [ ] ,
certStore : string = './certs' ,
2025-05-19 03:40:58 +00:00
acmeOptions? : any ,
initialState ? : { challengeRouteActive? : boolean }
2025-05-18 23:07:31 +00:00
) : Promise < SmartCertManager > {
2025-05-19 03:40:58 +00:00
const certManager = new SmartCertManager ( routes , certStore , acmeOptions , initialState ) ;
2025-05-18 23:07:31 +00:00
// Always set up the route update callback for ACME challenges
certManager . setUpdateRoutesCallback ( async ( routes ) = > {
await this . updateRoutes ( routes ) ;
} ) ;
2025-05-19 17:28:05 +00:00
// Connect with HttpProxy if available
if ( this . httpProxyBridge . getHttpProxy ( ) ) {
certManager . setHttpProxy ( this . httpProxyBridge . getHttpProxy ( ) ) ;
2025-05-18 23:07:31 +00:00
}
2025-05-19 03:40:58 +00:00
// Set the ACME state manager
certManager . setAcmeStateManager ( this . acmeStateManager ) ;
2025-05-18 23:07:31 +00:00
// Pass down the global ACME config if available
if ( this . settings . acme ) {
certManager . setGlobalAcmeDefaults ( this . settings . acme ) ;
}
await certManager . initialize ( ) ;
return certManager ;
}
2025-03-25 22:30:57 +00:00
/ * *
2025-05-18 15:38:07 +00:00
* Initialize certificate manager
2025-03-25 22:30:57 +00:00
* /
2025-05-18 15:38:07 +00:00
private async initializeCertificateManager ( ) : Promise < void > {
// Extract global ACME options if any routes use auto certificates
const autoRoutes = this . settings . routes . filter ( r = >
r . action . tls ? . certificate === 'auto'
) ;
if ( autoRoutes . length === 0 && ! this . hasStaticCertRoutes ( ) ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , 'No routes require certificate management' , { component : 'certificate-manager' } ) ;
2025-03-25 22:30:57 +00:00
return ;
}
2025-05-18 18:29:59 +00:00
// Prepare ACME options with priority:
// 1. Use top-level ACME config if available
// 2. Fall back to first auto route's ACME config
// 3. Otherwise use undefined
let acmeOptions : { email? : string ; useProduction? : boolean ; port? : number } | undefined ;
if ( this . settings . acme ? . email ) {
// Use top-level ACME config
acmeOptions = {
email : this.settings.acme.email ,
useProduction : this.settings.acme.useProduction || false ,
port : this.settings.acme.port || 80
} ;
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , ` Using top-level ACME configuration with email: ${ acmeOptions . email } ` , { component : 'certificate-manager' } ) ;
2025-05-18 18:29:59 +00:00
} else if ( autoRoutes . length > 0 ) {
// Check for route-level ACME config
const routeWithAcme = autoRoutes . find ( r = > r . action . tls ? . acme ? . email ) ;
if ( routeWithAcme ? . action . tls ? . acme ) {
const routeAcme = routeWithAcme . action . tls . acme ;
acmeOptions = {
email : routeAcme.email ,
useProduction : routeAcme.useProduction || false ,
port : routeAcme.challengePort || 80
} ;
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , ` Using route-level ACME configuration from route ' ${ routeWithAcme . name } ' with email: ${ acmeOptions . email } ` , { component : 'certificate-manager' } ) ;
2025-05-18 18:29:59 +00:00
}
}
// Validate we have required configuration
if ( autoRoutes . length > 0 && ! acmeOptions ? . email ) {
throw new Error (
'ACME email is required for automatic certificate provisioning. ' +
'Please provide email in either:\n' +
'1. Top-level "acme" configuration\n' +
'2. Individual route\'s "tls.acme" configuration'
) ;
}
2025-05-18 15:38:07 +00:00
2025-05-18 23:07:31 +00:00
// Use the helper method to create and configure the certificate manager
this . certManager = await this . createCertificateManager (
2025-05-18 15:38:07 +00:00
this . settings . routes ,
2025-05-18 18:29:59 +00:00
this . settings . acme ? . certificateStore || './certs' ,
acmeOptions
2025-05-18 15:38:07 +00:00
) ;
}
/ * *
* Check if we have routes with static certificates
* /
private hasStaticCertRoutes ( ) : boolean {
return this . settings . routes . some ( r = >
r . action . tls ? . certificate &&
r . action . tls . certificate !== 'auto'
) ;
2025-03-25 22:30:57 +00:00
}
/ * *
2025-05-10 00:01:02 +00:00
* Start the proxy server with support for both configuration types
2025-03-25 22:30:57 +00:00
* /
public async start() {
// Don't start if already shutting down
if ( this . isShuttingDown ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'warn' , "Cannot start SmartProxy while it's in the shutdown process" ) ;
2025-03-25 22:30:57 +00:00
return ;
}
2025-05-10 00:01:02 +00:00
// Validate the route configuration
const configWarnings = this . routeManager . validateConfiguration ( ) ;
2025-05-18 18:29:59 +00:00
// Also validate ACME configuration
const acmeWarnings = this . validateAcmeConfiguration ( ) ;
const allWarnings = [ . . . configWarnings , . . . acmeWarnings ] ;
if ( allWarnings . length > 0 ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'warn' , ` ${ allWarnings . length } configuration warnings found ` , { count : allWarnings.length } ) ;
2025-05-18 18:29:59 +00:00
for ( const warning of allWarnings ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'warn' , ` ${ warning } ` ) ;
2025-03-25 22:30:57 +00:00
}
}
2025-05-10 00:01:02 +00:00
// Get listening ports from RouteManager
const listeningPorts = this . routeManager . getListeningPorts ( ) ;
2025-03-25 22:30:57 +00:00
2025-05-20 15:32:19 +00:00
// Initialize port usage tracking
this . portUsageMap = this . updatePortUsageMap ( this . settings . routes ) ;
// Log port usage for startup
logger . log ( 'info' , ` SmartProxy starting with ${ listeningPorts . length } ports: ${ listeningPorts . join ( ', ' ) } ` , {
portCount : listeningPorts.length ,
ports : listeningPorts ,
component : 'smart-proxy'
} ) ;
2025-05-15 14:35:01 +00:00
// Provision NFTables rules for routes that use NFTables
for ( const route of this . settings . routes ) {
if ( route . action . forwardingEngine === 'nftables' ) {
await this . nftablesManager . provisionRoute ( route ) ;
}
}
2025-05-20 16:01:32 +00:00
// Initialize and start HttpProxy if needed - before port binding
if ( this . settings . useHttpProxy && this . settings . useHttpProxy . length > 0 ) {
await this . httpProxyBridge . initialize ( ) ;
await this . httpProxyBridge . start ( ) ;
}
// Start port listeners using the PortManager BEFORE initializing certificate manager
// This ensures all required ports are bound and ready when adding ACME challenge routes
2025-05-13 12:48:41 +00:00
await this . portManager . addPorts ( listeningPorts ) ;
2025-05-19 22:07:08 +00:00
2025-05-20 16:01:32 +00:00
// Initialize certificate manager AFTER port binding is complete
// This ensures the ACME challenge port is already bound and ready when needed
await this . initializeCertificateManager ( ) ;
// Connect certificate manager with HttpProxy if both are available
if ( this . certManager && this . httpProxyBridge . getHttpProxy ( ) ) {
this . certManager . setHttpProxy ( this . httpProxyBridge . getHttpProxy ( ) ) ;
}
2025-05-19 22:07:08 +00:00
// Now that ports are listening, provision any required certificates
if ( this . certManager ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , 'Starting certificate provisioning now that ports are ready' , { component : 'certificate-manager' } ) ;
2025-05-19 22:07:08 +00:00
await this . certManager . provisionAllCertificates ( ) ;
}
2025-03-25 22:30:57 +00:00
// 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 ;
2025-05-19 17:28:05 +00:00
let httpProxyConnections = 0 ;
2025-03-25 22:30:57 +00:00
// 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 ) {
2025-05-19 17:28:05 +00:00
httpProxyConnections ++ ;
2025-03-25 22:30:57 +00:00
}
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
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , 'Connection statistics' , {
activeConnections : connectionRecords.size ,
tls : {
total : tlsConnections ,
completed : completedTlsHandshakes ,
pending : pendingTlsHandshakes
} ,
nonTls : nonTlsConnections ,
keepAlive : keepAliveConnections ,
httpProxy : httpProxyConnections ,
longestRunning : {
incoming : plugins.prettyMs ( maxIncoming ) ,
outgoing : plugins.prettyMs ( maxOutgoing )
} ,
terminationStats : {
incoming : terminationStats.incoming ,
outgoing : terminationStats.outgoing
} ,
component : 'connection-manager'
} ) ;
2025-03-25 22:30:57 +00:00
} , this . settings . inactivityCheckInterval || 60000 ) ;
// Make sure the interval doesn't keep the process alive
if ( this . connectionLogger . unref ) {
this . connectionLogger . unref ( ) ;
}
}
2025-05-10 00:01:02 +00:00
/ * *
* Extract domain configurations from routes for certificate provisioning
2025-05-10 00:49:39 +00:00
*
* Note : This method has been removed as we now work directly with routes
2025-05-10 00:01:02 +00:00
* /
2025-03-25 22:30:57 +00:00
/ * *
* Stop the proxy server
* /
public async stop() {
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , 'SmartProxy shutting down...' ) ;
2025-03-25 22:30:57 +00:00
this . isShuttingDown = true ;
2025-05-13 12:48:41 +00:00
this . portManager . setShuttingDown ( true ) ;
2025-05-10 00:01:02 +00:00
2025-05-18 15:38:07 +00:00
// Stop certificate manager
if ( this . certManager ) {
await this . certManager . stop ( ) ;
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , 'Certificate manager stopped' ) ;
2025-05-01 15:39:20 +00:00
}
2025-05-15 14:35:01 +00:00
// Stop NFTablesManager
await this . nftablesManager . stop ( ) ;
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , 'NFTablesManager stopped' ) ;
2025-03-25 22:30:57 +00:00
// Stop the connection logger
if ( this . connectionLogger ) {
clearInterval ( this . connectionLogger ) ;
this . connectionLogger = null ;
}
2025-05-13 12:48:41 +00:00
// Stop all port listeners
await this . portManager . closeAll ( ) ;
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , 'All servers closed. Cleaning up active connections...' ) ;
2025-03-25 22:30:57 +00:00
// Clean up all active connections
this . connectionManager . clearConnections ( ) ;
2025-05-19 17:28:05 +00:00
// Stop HttpProxy
await this . httpProxyBridge . stop ( ) ;
2025-05-19 03:40:58 +00:00
// Clear ACME state manager
this . acmeStateManager . clear ( ) ;
2025-03-25 22:30:57 +00:00
2025-05-19 23:37:11 +00:00
logger . log ( 'info' , 'SmartProxy shutdown complete.' ) ;
2025-03-25 22:30:57 +00:00
}
/ * *
2025-05-10 00:49:39 +00:00
* Updates the domain configurations for the proxy
*
* Note : This legacy method has been removed . Use updateRoutes instead .
2025-03-25 22:30:57 +00:00
* /
2025-05-10 00:49:39 +00:00
public async updateDomainConfigs ( ) : Promise < void > {
2025-05-19 23:37:11 +00:00
logger . log ( 'warn' , 'Method updateDomainConfigs() is deprecated. Use updateRoutes() instead.' ) ;
2025-05-10 00:49:39 +00:00
throw new Error ( 'updateDomainConfigs() is deprecated - use updateRoutes() instead' ) ;
2025-03-25 22:30:57 +00:00
}
2025-05-19 03:40:58 +00:00
/ * *
* Verify the challenge route has been properly removed from routes
* /
private async verifyChallengeRouteRemoved ( ) : Promise < void > {
const maxRetries = 10 ;
const retryDelay = 100 ; // milliseconds
for ( let i = 0 ; i < maxRetries ; i ++ ) {
// Check if the challenge route is still in the active routes
const challengeRouteExists = this . settings . routes . some ( r = > r . name === 'acme-challenge' ) ;
if ( ! challengeRouteExists ) {
2025-05-20 15:44:48 +00:00
try {
logger . log ( 'info' , 'Challenge route successfully removed from routes' ) ;
} catch ( error ) {
// Silently handle logging errors
console . log ( '[INFO] Challenge route successfully removed from routes' ) ;
}
2025-05-19 03:40:58 +00:00
return ;
}
// Wait before retrying
await plugins . smartdelay . delayFor ( retryDelay ) ;
}
2025-05-19 23:37:11 +00:00
const error = ` Failed to verify challenge route removal after ${ maxRetries } attempts ` ;
2025-05-20 15:44:48 +00:00
try {
logger . log ( 'error' , error ) ;
} catch ( logError ) {
// Silently handle logging errors
console . log ( ` [ERROR] ${ error } ` ) ;
}
2025-05-19 23:37:11 +00:00
throw new Error ( error ) ;
2025-05-19 03:40:58 +00:00
}
2025-05-10 00:01:02 +00:00
/ * *
2025-05-10 00:49:39 +00:00
* 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' }
* }
* }
* ] ) ;
* ` ` `
2025-05-10 00:01:02 +00:00
* /
public async updateRoutes ( newRoutes : IRouteConfig [ ] ) : Promise < void > {
2025-05-19 03:40:58 +00:00
return this . routeUpdateLock . runExclusive ( async ( ) = > {
2025-05-20 15:44:48 +00:00
try {
2025-05-20 16:01:32 +00:00
logger . log ( 'info' , ` Updating routes ( ${ newRoutes . length } routes) ` , {
routeCount : newRoutes.length ,
component : 'route-manager'
} ) ;
2025-05-20 15:44:48 +00:00
} catch ( error ) {
// Silently handle logging errors
console . log ( ` [INFO] Updating routes ( ${ newRoutes . length } routes) ` ) ;
}
2025-05-10 00:49:39 +00:00
2025-05-20 15:32:19 +00:00
// Track port usage before and after updates
const oldPortUsage = this . updatePortUsageMap ( this . settings . routes ) ;
const newPortUsage = this . updatePortUsageMap ( newRoutes ) ;
2025-05-20 16:01:32 +00:00
// Get the lists of currently listening ports and new ports needed
const currentPorts = new Set ( this . portManager . getListeningPorts ( ) ) ;
const newPortsSet = new Set ( newPortUsage . keys ( ) ) ;
// Log the port usage for debugging
try {
logger . log ( 'debug' , ` Current listening ports: ${ Array . from ( currentPorts ) . join ( ', ' ) } ` , {
ports : Array.from ( currentPorts ) ,
component : 'smart-proxy'
} ) ;
logger . log ( 'debug' , ` Ports needed for new routes: ${ Array . from ( newPortsSet ) . join ( ', ' ) } ` , {
ports : Array.from ( newPortsSet ) ,
component : 'smart-proxy'
} ) ;
} catch ( error ) {
// Silently handle logging errors
console . log ( ` [DEBUG] Current listening ports: ${ Array . from ( currentPorts ) . join ( ', ' ) } ` ) ;
console . log ( ` [DEBUG] Ports needed for new routes: ${ Array . from ( newPortsSet ) . join ( ', ' ) } ` ) ;
}
2025-05-20 15:32:19 +00:00
// Find orphaned ports - ports that no longer have any routes
const orphanedPorts = this . findOrphanedPorts ( oldPortUsage , newPortUsage ) ;
2025-05-20 16:01:32 +00:00
// Find new ports that need binding (only ports that we aren't already listening on)
2025-05-20 15:32:19 +00:00
const newBindingPorts = Array . from ( newPortsSet ) . filter ( p = > ! currentPorts . has ( p ) ) ;
2025-05-20 16:01:32 +00:00
// Check for ACME challenge port to give it special handling
const acmePort = this . settings . acme ? . port || 80 ;
const acmePortNeeded = newPortsSet . has ( acmePort ) ;
const acmePortListed = newBindingPorts . includes ( acmePort ) ;
if ( acmePortNeeded && acmePortListed ) {
try {
logger . log ( 'info' , ` Adding ACME challenge port ${ acmePort } to routes ` , {
port : acmePort ,
component : 'smart-proxy'
} ) ;
} catch ( error ) {
// Silently handle logging errors
console . log ( ` [INFO] Adding ACME challenge port ${ acmePort } to routes ` ) ;
}
}
2025-05-20 15:32:19 +00:00
2025-05-20 16:01:32 +00:00
// Get existing routes that use NFTables and update them
2025-05-19 03:40:58 +00:00
const oldNfTablesRoutes = this . settings . routes . filter (
r = > r . action . forwardingEngine === 'nftables'
) ;
const newNfTablesRoutes = newRoutes . filter (
r = > r . action . forwardingEngine === 'nftables'
) ;
2025-05-15 14:35:01 +00:00
2025-05-20 16:01:32 +00:00
// Update existing NFTables routes
2025-05-19 03:40:58 +00:00
for ( const oldRoute of oldNfTablesRoutes ) {
const newRoute = newNfTablesRoutes . find ( r = > r . name === oldRoute . name ) ;
if ( ! newRoute ) {
// Route was removed
await this . nftablesManager . deprovisionRoute ( oldRoute ) ;
} else {
// Route was updated
await this . nftablesManager . updateRoute ( oldRoute , newRoute ) ;
}
2025-05-15 14:35:01 +00:00
}
2025-05-20 16:01:32 +00:00
// Add new NFTables routes
2025-05-19 03:40:58 +00:00
for ( const newRoute of newNfTablesRoutes ) {
const oldRoute = oldNfTablesRoutes . find ( r = > r . name === newRoute . name ) ;
if ( ! oldRoute ) {
// New route
await this . nftablesManager . provisionRoute ( newRoute ) ;
}
2025-05-15 14:35:01 +00:00
}
2025-05-19 03:40:58 +00:00
// Update routes in RouteManager
this . routeManager . updateRoutes ( newRoutes ) ;
2025-05-10 00:49:39 +00:00
2025-05-20 16:01:32 +00:00
// Release orphaned ports first to free resources
2025-05-20 15:32:19 +00:00
if ( orphanedPorts . length > 0 ) {
2025-05-20 15:44:48 +00:00
try {
logger . log ( 'info' , ` Releasing ${ orphanedPorts . length } orphaned ports: ${ orphanedPorts . join ( ', ' ) } ` , {
ports : orphanedPorts ,
component : 'smart-proxy'
} ) ;
} catch ( error ) {
// Silently handle logging errors
console . log ( ` [INFO] Releasing ${ orphanedPorts . length } orphaned ports: ${ orphanedPorts . join ( ', ' ) } ` ) ;
}
2025-05-20 15:32:19 +00:00
await this . portManager . removePorts ( orphanedPorts ) ;
}
2025-05-20 16:01:32 +00:00
// Add new ports if needed
2025-05-20 15:32:19 +00:00
if ( newBindingPorts . length > 0 ) {
2025-05-20 15:44:48 +00:00
try {
logger . log ( 'info' , ` Binding to ${ newBindingPorts . length } new ports: ${ newBindingPorts . join ( ', ' ) } ` , {
ports : newBindingPorts ,
component : 'smart-proxy'
} ) ;
} catch ( error ) {
// Silently handle logging errors
console . log ( ` [INFO] Binding to ${ newBindingPorts . length } new ports: ${ newBindingPorts . join ( ', ' ) } ` ) ;
}
2025-05-20 16:01:32 +00:00
// Handle port binding with improved error recovery
try {
await this . portManager . addPorts ( newBindingPorts ) ;
} catch ( error ) {
// Special handling for port binding errors
// This provides better diagnostics for ACME challenge port conflicts
if ( ( error as any ) . code === 'EADDRINUSE' ) {
const port = ( error as any ) . port || newBindingPorts [ 0 ] ;
const isAcmePort = port === acmePort ;
if ( isAcmePort ) {
try {
logger . log ( 'warn' , ` Could not bind to ACME challenge port ${ port } . It may be in use by another application. ` , {
port ,
component : 'smart-proxy'
} ) ;
} catch ( logError ) {
console . log ( ` [WARN] Could not bind to ACME challenge port ${ port } . It may be in use by another application. ` ) ;
}
// Re-throw with more helpful message
throw new Error (
` ACME challenge port ${ port } is already in use by another application. ` +
` Configure a different port in settings.acme.port (e.g., 8080) or free up port ${ port } . `
) ;
}
}
// Re-throw the original error for other cases
throw error ;
}
2025-05-20 15:32:19 +00:00
}
2025-05-19 03:40:58 +00:00
// Update settings with the new routes
this . settings . routes = newRoutes ;
2025-05-20 15:32:19 +00:00
// Save the new port usage map for future reference
this . portUsageMap = newPortUsage ;
2025-05-13 12:48:41 +00:00
2025-05-19 17:28:05 +00:00
// If HttpProxy is initialized, resync the configurations
if ( this . httpProxyBridge . getHttpProxy ( ) ) {
await this . httpProxyBridge . syncRoutesToHttpProxy ( newRoutes ) ;
2025-05-19 03:40:58 +00:00
}
2025-05-10 00:49:39 +00:00
2025-05-19 03:40:58 +00:00
// Update certificate manager with new routes
if ( this . certManager ) {
const existingAcmeOptions = this . certManager . getAcmeOptions ( ) ;
const existingState = this . certManager . getState ( ) ;
// Store global state before stopping
this . globalChallengeRouteActive = existingState . challengeRouteActive ;
2025-05-20 15:44:48 +00:00
// Only stop the cert manager if absolutely necessary
// First check if there's an ACME route on the same port already
const acmePort = existingAcmeOptions ? . port || 80 ;
const acmePortInUse = newPortUsage . has ( acmePort ) && newPortUsage . get ( acmePort ) ! . size > 0 ;
try {
logger . log ( 'debug' , ` ACME port ${ acmePort } ${ acmePortInUse ? 'is' : 'is not' } already in use by other routes ` , {
port : acmePort ,
inUse : acmePortInUse ,
component : 'smart-proxy'
} ) ;
} catch ( error ) {
// Silently handle logging errors
console . log ( ` [DEBUG] ACME port ${ acmePort } ${ acmePortInUse ? 'is' : 'is not' } already in use by other routes ` ) ;
}
2025-05-19 03:40:58 +00:00
await this . certManager . stop ( ) ;
// Verify the challenge route has been properly removed
await this . verifyChallengeRouteRemoved ( ) ;
// Create new certificate manager with preserved state
this . certManager = await this . createCertificateManager (
newRoutes ,
'./certs' ,
existingAcmeOptions ,
{ challengeRouteActive : this.globalChallengeRouteActive }
) ;
}
} ) ;
2025-05-10 00:01:02 +00:00
}
2025-03-25 22:30:57 +00:00
/ * *
2025-05-18 15:38:07 +00:00
* Manually provision a certificate for a route
2025-03-25 22:30:57 +00:00
* /
2025-05-18 15:38:07 +00:00
public async provisionCertificate ( routeName : string ) : Promise < void > {
if ( ! this . certManager ) {
throw new Error ( 'Certificate manager not initialized' ) ;
2025-03-25 22:30:57 +00:00
}
2025-05-18 15:38:07 +00:00
const route = this . settings . routes . find ( r = > r . name === routeName ) ;
if ( ! route ) {
throw new Error ( ` Route ${ routeName } not found ` ) ;
}
await this . certManager . provisionCertificate ( route ) ;
}
2025-05-20 15:32:19 +00:00
/ * *
* Update the port usage map based on the provided routes
*
* This tracks which ports are used by which routes , allowing us to
* detect when a port is no longer needed and can be released .
* /
private updatePortUsageMap ( routes : IRouteConfig [ ] ) : Map < number , Set < string > > {
// Reset the usage map
const portUsage = new Map < number , Set < string > > ( ) ;
for ( const route of routes ) {
// Get the ports for this route
const portsConfig = Array . isArray ( route . match . ports )
? route . match . ports
: [ route . match . ports ] ;
// Expand port range objects to individual port numbers
const expandedPorts : number [ ] = [ ] ;
for ( const portConfig of portsConfig ) {
if ( typeof portConfig === 'number' ) {
expandedPorts . push ( portConfig ) ;
} else if ( typeof portConfig === 'object' && 'from' in portConfig && 'to' in portConfig ) {
// Expand the port range
for ( let p = portConfig . from ; p <= portConfig . to ; p ++ ) {
expandedPorts . push ( p ) ;
}
}
}
// Use route name if available, otherwise generate a unique ID
const routeName = route . name || ` unnamed_ ${ Math . random ( ) . toString ( 36 ) . substring ( 2 , 9 ) } ` ;
// Add each port to the usage map
for ( const port of expandedPorts ) {
if ( ! portUsage . has ( port ) ) {
portUsage . set ( port , new Set ( ) ) ;
}
portUsage . get ( port ) ! . add ( routeName ) ;
}
}
// Log port usage for debugging
for ( const [ port , routes ] of portUsage . entries ( ) ) {
2025-05-20 15:44:48 +00:00
try {
logger . log ( 'debug' , ` Port ${ port } is used by ${ routes . size } routes: ${ Array . from ( routes ) . join ( ', ' ) } ` , {
port ,
routeCount : routes.size ,
component : 'smart-proxy'
} ) ;
} catch ( error ) {
// Silently handle logging errors
console . log ( ` [DEBUG] Port ${ port } is used by ${ routes . size } routes: ${ Array . from ( routes ) . join ( ', ' ) } ` ) ;
}
2025-05-20 15:32:19 +00:00
}
return portUsage ;
}
/ * *
* Find ports that have no routes in the new configuration
* /
private findOrphanedPorts ( oldUsage : Map < number , Set < string > > , newUsage : Map < number , Set < string > > ) : number [ ] {
const orphanedPorts : number [ ] = [ ] ;
for ( const [ port , routes ] of oldUsage . entries ( ) ) {
if ( ! newUsage . has ( port ) || newUsage . get ( port ) ! . size === 0 ) {
orphanedPorts . push ( port ) ;
2025-05-20 15:44:48 +00:00
try {
logger . log ( 'info' , ` Port ${ port } no longer has any associated routes, will be released ` , {
port ,
component : 'smart-proxy'
} ) ;
} catch ( error ) {
// Silently handle logging errors
console . log ( ` [INFO] Port ${ port } no longer has any associated routes, will be released ` ) ;
}
2025-05-20 15:32:19 +00:00
}
}
return orphanedPorts ;
}
2025-05-18 15:38:07 +00:00
/ * *
* Force renewal of a certificate
* /
public async renewCertificate ( routeName : string ) : Promise < void > {
if ( ! this . certManager ) {
throw new Error ( 'Certificate manager not initialized' ) ;
}
await this . certManager . renewCertificate ( routeName ) ;
}
/ * *
* Get certificate status for a route
* /
public getCertificateStatus ( routeName : string ) : ICertStatus | undefined {
if ( ! this . certManager ) {
return undefined ;
2025-03-25 22:30:57 +00:00
}
2025-05-18 15:38:07 +00:00
return this . certManager . getCertificateStatus ( routeName ) ;
2025-03-25 22:30:57 +00:00
}
/ * *
* 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 ( '*' ) ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'warn' , ` Wildcard domains like " ${ domain } " are not supported for automatic ACME certificates ` , { domain , component : 'certificate-manager' } ) ;
2025-03-25 22:30:57 +00:00
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 ) ) {
2025-05-19 23:37:11 +00:00
logger . log ( 'warn' , ` Domain " ${ domain } " has invalid format for certificate issuance ` , { domain , component : 'certificate-manager' } ) ;
2025-03-25 22:30:57 +00:00
return false ;
}
return true ;
}
2025-05-13 12:48:41 +00:00
/ * *
* 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 ( ) ;
}
2025-03-25 22:30:57 +00:00
/ * *
* 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 ;
2025-05-19 17:28:05 +00:00
let httpProxyConnections = 0 ;
2025-03-25 22:30:57 +00:00
// Analyze active connections
for ( const record of connectionRecords . values ( ) ) {
if ( record . isTLS ) tlsConnections ++ ;
else nonTlsConnections ++ ;
if ( record . hasKeepAlive ) keepAliveConnections ++ ;
2025-05-19 17:28:05 +00:00
if ( record . usingNetworkProxy ) httpProxyConnections ++ ;
2025-03-25 22:30:57 +00:00
}
return {
activeConnections : connectionRecords.size ,
tlsConnections ,
nonTlsConnections ,
keepAliveConnections ,
2025-05-19 17:28:05 +00:00
httpProxyConnections ,
2025-03-25 22:30:57 +00:00
terminationStats ,
2025-05-18 15:38:07 +00:00
acmeEnabled : ! ! this . certManager ,
port80HandlerPort : this.certManager ? 80 : null ,
2025-05-13 12:48:41 +00:00
routes : this.routeManager.getListeningPorts ( ) . length ,
listeningPorts : this.portManager.getListeningPorts ( ) ,
activePorts : this.portManager.getListeningPorts ( ) . length
2025-03-25 22:30:57 +00:00
} ;
}
/ * *
* Get a list of eligible domains for ACME certificates
* /
public getEligibleDomainsForCertificates ( ) : string [ ] {
const domains : string [ ] = [ ] ;
2025-05-10 00:01:02 +00:00
// Get domains from routes
2025-05-15 08:56:27 +00:00
const routes = this . settings . routes || [ ] ;
2025-05-10 00:01:02 +00:00
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 ] ;
2025-03-25 22:30:57 +00:00
// Skip domains that can't be used with ACME
2025-05-10 00:01:02 +00:00
const eligibleDomains = routeDomains . filter ( domain = >
2025-03-25 22:30:57 +00:00
! domain . includes ( '*' ) && this . isValidDomain ( domain )
) ;
domains . push ( . . . eligibleDomains ) ;
}
2025-05-10 07:34:35 +00:00
// Legacy mode is no longer supported
2025-05-10 00:01:02 +00:00
2025-03-25 22:30:57 +00:00
return domains ;
}
2025-05-15 14:35:01 +00:00
/ * *
* Get NFTables status
* /
public async getNfTablesStatus ( ) : Promise < Record < string , any > > {
return this . nftablesManager . getStatus ( ) ;
}
2025-05-18 18:29:59 +00:00
/ * *
* Validate ACME configuration
* /
private validateAcmeConfiguration ( ) : string [ ] {
const warnings : string [ ] = [ ] ;
// Check for routes with certificate: 'auto'
const autoRoutes = this . settings . routes . filter ( r = >
r . action . tls ? . certificate === 'auto'
) ;
if ( autoRoutes . length === 0 ) {
return warnings ;
}
// Check if we have ACME email configuration
const hasTopLevelEmail = this . settings . acme ? . email ;
const routesWithEmail = autoRoutes . filter ( r = > r . action . tls ? . acme ? . email ) ;
if ( ! hasTopLevelEmail && routesWithEmail . length === 0 ) {
warnings . push (
'Routes with certificate: "auto" require ACME email configuration. ' +
'Add email to either top-level "acme" config or individual route\'s "tls.acme" config.'
) ;
}
// Check for port 80 availability for challenges
if ( autoRoutes . length > 0 ) {
const challengePort = this . settings . acme ? . port || 80 ;
const portsInUse = this . routeManager . getListeningPorts ( ) ;
if ( ! portsInUse . includes ( challengePort ) ) {
warnings . push (
` Port ${ challengePort } is not configured for any routes but is needed for ACME challenges. ` +
` Add a route listening on port ${ challengePort } or ensure it's accessible for HTTP-01 challenges. `
) ;
}
}
// Check for mismatched environments
if ( this . settings . acme ? . useProduction ) {
const stagingRoutes = autoRoutes . filter ( r = >
r . action . tls ? . acme ? . useProduction === false
) ;
if ( stagingRoutes . length > 0 ) {
warnings . push (
'Top-level ACME uses production but some routes use staging. ' +
'Consider aligning environments to avoid certificate issues.'
) ;
}
}
// Check for wildcard domains with auto certificates
for ( const route of autoRoutes ) {
const domains = Array . isArray ( route . match . domains )
? route . match . domains
: [ route . match . domains ] ;
const wildcardDomains = domains . filter ( d = > d ? . includes ( '*' ) ) ;
if ( wildcardDomains . length > 0 ) {
warnings . push (
` Route " ${ route . name } " has wildcard domain(s) ${ wildcardDomains . join ( ', ' ) } ` +
'with certificate: "auto". Wildcard certificates require DNS-01 challenges, ' +
'which are not currently supported. Use static certificates instead.'
) ;
}
}
return warnings ;
}
2025-05-15 14:35:01 +00:00
2025-03-25 22:30:57 +00:00
}