@ -5,20 +5,21 @@ export interface IDomainConfig {
domain : string ; // Glob pattern for domain
allowedIPs : string [ ] ; // Glob patterns for allowed IPs
targetIP? : string ; // Optional target IP for this domain
portRanges : Array < { from : number ; to : number } > ; // D omain-specific allowed port ranges
portRanges? : Array < { from : number ; to : number } > ; // Optional d omain-specific allowed port ranges
}
/** Port proxy settings including global allowed port ranges */
export interface IPortProxySettings extends plugins . tls . TlsOptions {
fromPort : number ;
toPort : number ;
toHost ? : string ; // T arget host to proxy to, defaults to 'localhost'
targetIP ? : string ; // Global t arget host to proxy to, defaults to 'localhost'
domains : IDomainConfig [ ] ;
sniEnabled? : boolean ;
defaultAllowedIPs? : string [ ] ;
preserveSourceIP? : boolean ;
maxConnectionLifetime? : number ; // (ms) force cleanup of long-lived connections
globalPortRanges : Array < { from : number ; to : number } > ; // Global allowed port ranges
forwardAllGlobalRanges? : boolean ; // When true, forwards all connections on global port ranges to the global targetIP
}
/**
@ -94,7 +95,7 @@ interface IConnectionRecord {
}
export class PortProxy {
netServer : plugins.net.Server ;
private netServers : plugins.net.Server [ ] = [ ] ;
settings : IPortProxySettings ;
// Unified record tracking each connection pair.
private connectionRecords : Set < IConnectionRecord > = new Set ( ) ;
@ -111,7 +112,7 @@ export class PortProxy {
constructor ( settingsArg : IPortProxySettings ) {
this . settings = {
. . . settingsArg ,
toHost : settingsArg.toHost || 'localhost' ,
targetIP : settingsArg.targetIP || 'localhost' ,
maxConnectionLifetime : settingsArg.maxConnectionLifetime || 600000 ,
} ;
}
@ -121,43 +122,8 @@ export class PortProxy {
}
public async start() {
// Helper to forcefully destroy socke ts.
const cleanUpSockets = ( socketA : plugins.net.Socket , socketB? : plugins.net.Socket ) = > {
if ( ! socketA . destroyed ) socketA . destroy ( ) ;
if ( socketB && ! socketB . destroyed ) socketB . destroy ( ) ;
} ;
// Normalize an IP to include both IPv4 and IPv6 representations.
const normalizeIP = ( ip : string ) : string [ ] = > {
if ( ip . startsWith ( '::ffff:' ) ) {
const ipv4 = ip . slice ( 7 ) ;
return [ ip , ipv4 ] ;
}
if ( /^\d{1,3}(\.\d{1,3}){3}$/ . test ( ip ) ) {
return [ ip , ` ::ffff: ${ ip } ` ] ;
}
return [ ip ] ;
} ;
// Check if a given IP matches any of the glob patterns.
const isAllowed = ( ip : string , patterns : string [ ] ) : boolean = > {
const normalizedIPVariants = normalizeIP ( ip ) ;
const expandedPatterns = patterns . flatMap ( normalizeIP ) ;
return normalizedIPVariants . some ( ipVariant = >
expandedPatterns . some ( pattern = > plugins . minimatch ( ipVariant , pattern ) )
) ;
} ;
// Check if a port falls within any of the given port ranges.
const isPortInRanges = ( port : number , ranges : Array < { from : number ; to : number } > ) : boolean = > {
return ranges . some ( range = > port >= range . from && port <= range . to ) ;
} ;
// Find a matching domain config based on SNI (fallback when port ranges aren’ t used)
const findMatchingDomain = ( serverName : string ) : IDomainConfig | undefined = >
this . settings . domains . find ( config = > plugins . minimatch ( serverName , config . domain ) ) ;
this . netServer = plugins . net . createServer ( ( socket : plugins.net.Socket ) = > {
// Define a unified connection handler for all listening por ts.
const connectionHandler = ( socket : plugins.net.Socket ) = > {
const remoteIP = socket . remoteAddress || '' ;
const localPort = socket . localPort ; // The port on which this connection was accepted.
const connectionRecord : IConnectionRecord = {
@ -180,7 +146,8 @@ export class PortProxy {
if ( connectionRecord . cleanupTimer ) {
clearTimeout ( connectionRecord . cleanupTimer ) ;
}
cleanUpS ockets ( connectionRecord . incoming , connectionRecord . outgoing || undefined ) ;
if ( ! s ocket. destroyed ) socket . destroy ( ) ;
if ( connectionRecord . outgoing && ! connectionRecord . outgoing . destroyed ) connectionRecord . outgoing . destroy ( ) ;
this . connectionRecords . delete ( connectionRecord ) ;
console . log ( ` Connection from ${ remoteIP } terminated. Active connections: ${ this . connectionRecords . size } ` ) ;
}
@ -243,7 +210,7 @@ export class PortProxy {
*/
const setupConnection = ( serverName : string , initialChunk? : Buffer , forcedDomain? : IDomainConfig ) = > {
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
const domainConfig = forcedDomain ? forcedDomain : ( serverName ? findMatchingDomain ( serverName ) : undefined ) ;
const domainConfig = forcedDomain ? forcedDomain : ( serverName ? this . settings . domains . find ( config = > plugins . minimatch ( serverName , config . domain ) ) : undefined ) ;
const defaultAllowed = this . settings . defaultAllowedIPs && isAllowed ( remoteIP , this . settings . defaultAllowedIPs ) ;
if ( ! defaultAllowed && serverName && ! forcedDomain ) {
@ -256,7 +223,7 @@ export class PortProxy {
} else if ( defaultAllowed && ! serverName ) {
console . log ( ` Connection allowed: IP ${ remoteIP } is in default allowed list ` ) ;
}
const targetHost = domainConfig ? . targetIP || this . settings . toHost ! ;
const targetHost = domainConfig ? . targetIP || this . settings . targetIP ! ;
const connectionOptions : plugins.net.NetConnectOpts = {
host : targetHost ,
port : this.settings.toPort ,
@ -343,36 +310,44 @@ export class PortProxy {
} ;
// --- PORT RANGE-BASED HANDLING ---
// If g lob al port ranges are defined, enforce port-based routing and ignore SNI .
// If the loc al port is one of the globally listened ports, we may have port-based rules .
if ( this . settings . globalPortRanges && this . settings . globalPortRanges . length > 0 ) {
if ( ! isPortInRanges ( localPort , this . settings . globalPortRanges ) ) {
console . log ( ` Connection from ${ remoteIP } rejected: port ${ localPort } is not in global allowed r anges. ` ) ;
socket . destroy ( ) ;
// If forwardAllGlobalRanges is enabled, always forward using the global targetIP.
if ( this . settings . forwardAllGlobalR anges) {
if ( this . settings . defaultAllowedIPs && ! isAllowed ( remoteIP , this . settings . defaultAllowedIPs ) ) {
console . log ( ` Connection from ${ remoteIP } rejected: IP ${ remoteIP } not allowed in global default allowed list. ` ) ;
socket . end ( ) ;
return ;
}
console . log ( ` Port-based connection from ${ remoteIP } on port ${ localPort } forwarded to global target IP ${ this . settings . targetIP } . ` ) ;
setupConnection ( '' , undefined , {
domain : 'global' ,
allowedIPs : this.settings.defaultAllowedIPs || [ ] ,
targetIP : this.settings.targetIP ,
portRanges : [ ]
} ) ;
return ;
} else {
// Attempt to find a matching forced domain config based on the local port.
const forcedDomain = this . settings . domains . find (
domain = > domain . portRanges && domain . portRanges . length > 0 && isPortInRanges ( localPort , domain . portRanges )
) ;
if ( forcedDomain ) {
const defaultAllowed = this . settings . defaultAllowedIPs && isAllowed ( remoteIP , this . settings . defaultAllowedIPs ) ;
if ( ! defaultAllowed && ! isAllowed ( remoteIP , forcedDomain . allowedIPs ) ) {
console . log ( ` Connection from ${ remoteIP } rejected: IP not allowed for domain ${ forcedDomain . domain } on port ${ localPort } . ` ) ;
socket . end ( ) ;
return ;
}
console . log ( ` Port-based connection from ${ remoteIP } on port ${ localPort } matched domain ${ forcedDomain . domain } . ` ) ;
setupConnection ( '' , undefined , forcedDomain ) ;
return ;
}
// Fall through to SNI/default handling if no forced domain config is found.
}
// Find a matching domain config based on the incoming local port.
const forcedDomain = this . settings . domains . find (
domain = > domain . portRanges && domain . portRanges . length > 0 && isPortInRanges ( localPort , domain . portRanges )
) ;
if ( ! forcedDomain ) {
console . log ( ` Connection from ${ remoteIP } rejected: port ${ localPort } not configured in any domain's portRanges. ` ) ;
socket . destroy ( ) ;
return ;
}
// Check allowed IPs for the forced domain.
const defaultAllowed = this . settings . defaultAllowedIPs && isAllowed ( remoteIP , this . settings . defaultAllowedIPs ) ;
if ( ! defaultAllowed && ! isAllowed ( remoteIP , forcedDomain . allowedIPs ) ) {
console . log ( ` Connection from ${ remoteIP } rejected: IP not allowed for domain ${ forcedDomain . domain } on port ${ localPort } . ` ) ;
socket . end ( ) ;
return ;
}
console . log ( ` Port-based connection from ${ remoteIP } on port ${ localPort } matched domain ${ forcedDomain . domain } . ` ) ;
// Proceed immediately using the forced domain; ignore SNI.
setupConnection ( '' , undefined , forcedDomain ) ;
return ;
}
// --- FALLBACK: SNI-BASED HANDLING (if no global port ranges are defin ed) ---
// --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabl ed) ---
if ( this . settings . sniEnabled ) {
socket . setTimeout ( 5000 , ( ) = > {
console . log ( ` Initial data timeout for ${ remoteIP } ` ) ;
@ -394,16 +369,36 @@ export class PortProxy {
}
setupConnection ( '' ) ;
}
} )
. on ( 'error' , ( err : Error ) = > {
console . log ( ` Server Error: ${ err . message } ` ) ;
} )
. listen ( this . settings . fromPort , ( ) = > {
console . log (
` PortProxy -> OK: Now l istening on port ${ this . settings . fromPort } ` +
` ${ this . settings . sniEnabled ? ' (SNI passthrough enabled)' : '' } `
) ;
} ;
// --- SETUP LISTENERS ---
// Determine which ports to listen on.
const listeningPorts = new Set < number > ( ) ;
if ( this . settings . globalPortRanges && this . settings . globalPortRanges . length > 0 ) {
// L isten on every port defined by the global ranges.
for ( const range of this . settings . globalPortRanges ) {
for ( let port = range . from ; port <= range . to ; port ++ ) {
listeningPorts . add ( port ) ;
}
}
// Also ensure the default fromPort is listened to if it isn’ t already in the ranges.
listeningPorts . add ( this . settings . fromPort ) ;
} else {
listeningPorts . add ( this . settings . fromPort ) ;
}
// Create a server for each port.
for ( const port of listeningPorts ) {
const server = plugins . net
. createServer ( connectionHandler )
. on ( 'error' , ( err : Error ) = > {
console . log ( ` Server Error on port ${ port } : ${ err . message } ` ) ;
} ) ;
server . listen ( port , ( ) = > {
console . log ( ` PortProxy -> OK: Now listening on port ${ port } ${ this . settings . sniEnabled ? ' (SNI passthrough enabled)' : '' } ` ) ;
} ) ;
this . netServers . push ( server ) ;
}
// Log active connection count and longest running durations every 10 seconds.
this . connectionLogger = setInterval ( ( ) = > {
@ -426,14 +421,41 @@ export class PortProxy {
}
public async stop() {
const done = plugins . smartpromise . defer ( ) ;
this . netServer . close ( ( ) = > {
done . resolve ( ) ;
} ) ;
// Close all servers.
const closePromises : Promise < void > [ ] = this . netServers . map (
server = >
new Promise < void > ( ( resolve ) = > {
server . close ( ( ) = > resolve ( ) ) ;
} )
) ;
if ( this . connectionLogger ) {
clearInterval ( this . connectionLogger ) ;
this . connectionLogger = null ;
}
await done . p romise;
await Promise . all ( closeP romises ) ;
}
}
}
// Helper: Check if a port falls within any of the given port ranges.
const isPortInRanges = ( port : number , ranges : Array < { from : number ; to : number } > ) : boolean = > {
return ranges . some ( range = > port >= range . from && port <= range . to ) ;
} ;
// Helper: Check if a given IP matches any of the glob patterns.
const isAllowed = ( ip : string , patterns : string [ ] ) : boolean = > {
const normalizeIP = ( ip : string ) : string [ ] = > {
if ( ip . startsWith ( '::ffff:' ) ) {
const ipv4 = ip . slice ( 7 ) ;
return [ ip , ipv4 ] ;
}
if ( /^\d{1,3}(\.\d{1,3}){3}$/ . test ( ip ) ) {
return [ ip , ` ::ffff: ${ ip } ` ] ;
}
return [ ip ] ;
} ;
const normalizedIPVariants = normalizeIP ( ip ) ;
const expandedPatterns = patterns . flatMap ( normalizeIP ) ;
return normalizedIPVariants . some ( ipVariant = >
expandedPatterns . some ( pattern = > plugins . minimatch ( ipVariant , pattern ) )
) ;
} ;