@ -1,20 +1,25 @@
import * as plugins from './plugins.js' ;
/** Domain configuration with per‐ domain allowed port ranges */
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 } > ; // Optional domain-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 ; // New option (in milliseconds) to force cleanup of long-lived connections
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
}
/**
@ -90,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 ( ) ;
@ -107,7 +112,7 @@ export class PortProxy {
constructor ( settingsArg : IPortProxySettings ) {
this . settings = {
. . . settingsArg ,
toHost : settingsArg.toHost || 'localhost' ,
targetIP : settingsArg.targetIP || 'localhost' ,
maxConnectionLifetime : settingsArg.maxConnectionLifetime || 600000 ,
} ;
}
@ -117,39 +122,10 @@ 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 ) )
) ;
} ;
// Find a matching domain config based on the SNI.
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 = {
incoming : socket ,
outgoing : null ,
@ -157,7 +133,7 @@ export class PortProxy {
connectionClosed : false ,
} ;
this . connectionRecords . add ( connectionRecord ) ;
console . log ( ` New connection from ${ remoteIP } . Active connections: ${ this . connectionRecords . size } ` ) ;
console . log ( ` New connection from ${ remoteIP } on port ${ localPort } . Active connections: ${ this . connectionRecords . size } ` ) ;
let initialDataReceived = false ;
let incomingTerminationReason : string | null = null ;
@ -170,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 } ` ) ;
}
@ -225,25 +202,28 @@ export class PortProxy {
cleanupOnce ( ) ;
} ;
const setupConnection = ( serverName : string , initialChunk? : Buffer ) = > {
/**
* Sets up the connection to the target host.
* @param serverName - The SNI hostname (unused when forcedDomain is provided).
* @param initialChunk - Optional initial data chunk.
* @param forcedDomain - If provided, overrides SNI/domain lookup (used for port-based routing).
*/
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 ? this . settings . domains . find ( config = > plugins . minimatch ( serverName , config . domain ) ) : undefined ) ;
const defaultAllowed = this . settings . defaultAllowedIPs && isAllowed ( remoteIP , this . settings . defaultAllowedIPs ) ;
if ( ! defaultAllowed && serverName ) {
const domainConfig = findMatchingDomain ( serverName ) ;
if ( ! defaultAllowed && serverName && ! forcedDomain ) {
if ( ! domainConfig ) {
return rejectIncomingConnection ( 'rejected' , ` Connection rejected: No matching domain config for ${ serverName } from ${ remoteIP } ` ) ;
}
if ( ! isAllowed ( remoteIP , domainConfig . allowedIPs ) ) {
return rejectIncomingConnection ( 'rejected' , ` Connection rejected: IP ${ remoteIP } not allowed for domain ${ serverName } ` ) ;
}
} else if ( ! defaultAllowed && ! serverName ) {
return rejectIncomingConnection ( 'rejected' , ` Connection rejected: No SNI and IP ${ remoteIP } not in default allowed list ` ) ;
} else if ( defaultAllowed && ! serverName ) {
console . log ( ` Connection allowed: IP ${ remoteIP } is in default allowed list ` ) ;
}
const domainConfig = serverName ? findMatchingDomain ( serverName ) : undefined ;
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 ,
@ -258,7 +238,7 @@ export class PortProxy {
console . log (
` Connection established: ${ remoteIP } -> ${ targetHost } : ${ this . settings . toPort } ` +
` ${ serverName ? ` (SNI: ${ serverName } ) ` : '' } `
` ${ serverName ? ` (SNI: ${ serverName } ) ` : forcedDomain ? ` (Port-based for domain: ${ forcedDomain . domain } ) ` : '' } `
) ;
if ( initialChunk ) {
@ -292,7 +272,7 @@ export class PortProxy {
socket . on ( 'end' , handleClose ( 'incoming' ) ) ;
targetSocket . on ( 'end' , handleClose ( 'outgoing' ) ) ;
// If maxConnectionLifetime is set, initialize a cleanup timer that will be reset on data flow .
// Initialize a cleanup timer for max connection lifetime .
if ( this . settings . maxConnectionLifetime ) {
let incomingActive = false ;
let outgoingActive = false ;
@ -308,10 +288,8 @@ export class PortProxy {
}
} ;
// Start the cleanup timer.
resetCleanupTimer ( ) ;
// Listen for data events on both sides and reset the timer when both are active.
socket . on ( 'data' , ( ) = > {
incomingActive = true ;
if ( incomingActive && outgoingActive ) {
@ -331,6 +309,45 @@ export class PortProxy {
}
} ;
// --- PORT RANGE-BASED HANDLING ---
// If the local port is one of the globally listened ports, we may have port-based rules.
if ( this . settings . globalPortRanges && this . settings . globalPortRanges . length > 0 ) {
// If forwardAllGlobalRanges is enabled, always forward using the global targetIP.
if ( this . settings . forwardAllGlobalRanges ) {
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.
}
}
// --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabled) ---
if ( this . settings . sniEnabled ) {
socket . setTimeout ( 5000 , ( ) = > {
console . log ( ` Initial data timeout for ${ remoteIP } ` ) ;
@ -352,18 +369,38 @@ export class PortProxy {
}
setupConnection ( '' ) ;
}
} )
. on ( 'error' , ( err : Error ) = > {
console . log ( ` Server Error: ${ err . message } ` ) ;
} )
. listen ( this . settings . fromPort , ( ) = > {
console . log (
` PortProxy -> OK: Now listening on port ${ this . settings . fromPort } ` +
` ${ this . settings . sniEnabled ? ' (SNI passthrough enabled)' : '' } `
) ;
} ) ;
} ;
// Every 10 seconds log active connection count and longest running durations.
// --- SETUP LISTENERS ---
// Determine which ports to listen on.
const listeningPorts = new Set < number > ( ) ;
if ( this . settings . globalPortRanges && this . settings . globalPortRanges . length > 0 ) {
// Listen 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 ( ( ) = > {
const now = Date . now ( ) ;
let maxIncoming = 0 ;
@ -384,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 ) )
) ;
} ;