@ -1,9 +1,10 @@
import * as plugins from './plugins.js' ;
/** Domain configuration with per‐ domain allowed port ranges */
/** Domain configuration with per- domain allowed port ranges */
export interface IDomainConfig {
domains : string [ ] ; // Glob patterns for domain(s)
allowedIPs : string [ ] ; // Glob patterns for allowed IPs
blockedIPs? : string [ ] ; // Glob patterns for blocked IPs
targetIPs? : string [ ] ; // If multiple targetIPs are given, use round robin.
portRanges? : Array < { from : number ; to : number } > ; // Optional port ranges
}
@ -16,6 +17,7 @@ export interface IPortProxySettings extends plugins.tls.TlsOptions {
domainConfigs : IDomainConfig [ ] ;
sniEnabled? : boolean ;
defaultAllowedIPs? : string [ ] ;
defaultBlockedIPs? : string [ ] ;
preserveSourceIP? : boolean ;
maxConnectionLifetime? : number ; // (ms) force cleanup of long-lived connections
globalPortRanges : Array < { from : number ; to : number } > ; // Global allowed port ranges
@ -90,11 +92,42 @@ interface IConnectionRecord {
outgoing : plugins.net.Socket | null ;
incomingStartTime : number ;
outgoingStartTime? : number ;
outgoingClosedTime? : number ;
lockedDomain? : string ; // New field to lock this connection to the initial SNI
connectionClosed : boolean ;
cleanupTimer? : NodeJS.Timeout ; // Timer to force cleanup after max lifetime/inactivity
}
// 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 ) )
) ;
} ;
// Helper: Check if an IP is allowed considering allowed and blocked glob patterns.
const isGlobIPAllowed = ( ip : string , allowed : string [ ] , blocked : string [ ] = [ ] ) : boolean = > {
if ( blocked . length > 0 && isAllowed ( ip , blocked ) ) return false ;
return isAllowed ( ip , allowed ) ;
} ;
export class PortProxy {
private netServers : plugins.net.Server [ ] = [ ] ;
settings : IPortProxySettings ;
@ -125,6 +158,33 @@ export class PortProxy {
this . terminationStats [ side ] [ reason ] = ( this . terminationStats [ side ] [ reason ] || 0 ) + 1 ;
}
/**
* Cleans up a connection record if not already cleaned up.
* Destroys both incoming and outgoing sockets, clears timers, and removes the record.
* Logs the cleanup event.
*/
private cleanupConnection ( record : IConnectionRecord , special : boolean = false ) : void {
if ( ! record . connectionClosed ) {
record . connectionClosed = true ;
if ( record . cleanupTimer ) {
clearTimeout ( record . cleanupTimer ) ;
}
if ( ! record . incoming . destroyed ) {
record . incoming . destroy ( ) ;
}
if ( record . outgoing && ! record . outgoing . destroyed ) {
record . outgoing . destroy ( ) ;
}
this . connectionRecords . delete ( record ) ;
const remoteIP = record . incoming . remoteAddress || 'unknown' ;
if ( special ) {
console . log ( ` Special parity cleanup: Connection from ${ remoteIP } cleaned up due to duration difference. ` ) ;
} else {
console . log ( ` Connection from ${ remoteIP } terminated. Active connections: ${ this . connectionRecords . size } ` ) ;
}
}
}
private getTargetIP ( domainConfig : IDomainConfig ) : string {
if ( domainConfig . targetIPs && domainConfig . targetIPs . length > 0 ) {
const currentIndex = this . domainTargetIndices . get ( domainConfig ) || 0 ;
@ -153,18 +213,9 @@ export class PortProxy {
let incomingTerminationReason : string | null = null ;
let outgoingTerminationReason : string | null = null ;
// Ensure cleanup happens only once for the entire connection recor d.
const cleanupOnce = async ( ) = > {
if ( ! c onnectionRecord . connectionClose d ) {
connectionRecord . connectionClosed = true ;
if ( connectionRecord . cleanupTimer ) {
clearTimeout ( connectionRecord . cleanupTimer ) ;
}
if ( ! socket . 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 } ` ) ;
}
// Local cleanup function that delegates to the class metho d.
const cleanupOnce = ( ) = > {
this . cleanupC onnection( connectionRecor d ) ;
} ;
// Helper to reject an incoming connection.
@ -212,6 +263,8 @@ export class PortProxy {
} else if ( side === 'outgoing' && outgoingTerminationReason === null ) {
outgoingTerminationReason = 'normal' ;
this . incrementTerminationStat ( 'outgoing' , 'normal' ) ;
// Record the time when outgoing socket closed.
connectionRecord . outgoingClosedTime = Date . now ( ) ;
}
cleanupOnce ( ) ;
} ;
@ -231,17 +284,25 @@ export class PortProxy {
config . domains . some ( d = > plugins . minimatch ( serverName , d ) )
) : undefined ) ;
// If a matching domain config exists, check its allowed IPs.
// Effective IP check: merge allowed IPs with default allowed, and remove blocked IPs.
if ( domainConfig ) {
if ( ! isAllowed ( remoteIP , domainConfig . allowedIPs ) ) {
const effectiveAllowedIPs : string [ ] = [
. . . domainConfig . allowedIPs ,
. . . ( this . settings . defaultAllowedIPs || [ ] )
] ;
const effectiveBlockedIPs : string [ ] = [
. . . ( domainConfig . blockedIPs || [ ] ) ,
. . . ( this . settings . defaultBlockedIPs || [ ] )
] ;
if ( ! isGlobIPAllowed ( remoteIP , effectiveAllowedIPs , effectiveBlockedIPs ) ) {
return rejectIncomingConnection ( 'rejected' , ` Connection rejected: IP ${ remoteIP } not allowed for domain ${ domainConfig . domains . join ( ', ' ) } ` ) ;
}
} else if ( this . settings . defaultAllowedIPs ) {
// Only check default a llowed IPs if no domain config matched.
if ( ! isAllowed ( remoteIP , this . settings . defaultAllowedIPs ) ) {
if ( ! isGlobIPAllowed ( remoteIP , this . settings . defaultA llowedIPs, this . settings . defaultBlockedIPs || [ ] ) ) {
return rejectIncomingConnection ( 'rejected' , ` Connection rejected: IP ${ remoteIP } not allowed by default allowed list ` ) ;
}
}
const targetHost = domainConfig ? this . getTargetIP ( domainConfig ) : this . settings . targetIP ! ;
const connectionOptions : plugins.net.NetConnectOpts = {
host : targetHost ,
@ -293,6 +354,7 @@ export class PortProxy {
// Initialize a cleanup timer for max connection lifetime.
if ( this . settings . maxConnectionLifetime ) {
// Flags to track if data was seen from each side.
let incomingActive = false ;
let outgoingActive = false ;
const resetCleanupTimer = ( ) = > {
@ -309,15 +371,19 @@ export class PortProxy {
resetCleanupTimer ( ) ;
// Only reset the timer if outgoing socket is still active.
socket . on ( 'data' , ( ) = > {
incomingActive = true ;
if ( incomingActive && outgoingActive ) {
// Check if outgoing has not been closed before resetting timer.
if ( ! connectionRecord . outgoingClosedTime && incomingActive && outgoingActive ) {
resetCleanupTimer ( ) ;
incomingActive = false ;
outgoingActive = false ;
}
} ) ;
targetSocket . on ( 'data' , ( ) = > {
// If outgoing is closed, do not set outgoingActive.
if ( connectionRecord . outgoingClosedTime ) return ;
outgoingActive = true ;
if ( incomingActive && outgoingActive ) {
resetCleanupTimer ( ) ;
@ -341,6 +407,7 @@ export class PortProxy {
setupConnection ( '' , undefined , {
domains : [ 'global' ] ,
allowedIPs : this.settings.defaultAllowedIPs || [ ] ,
blockedIPs : this.settings.defaultBlockedIPs || [ ] ,
targetIPs : [ this . settings . targetIP ! ] ,
portRanges : [ ]
} , localPort ) ;
@ -351,7 +418,15 @@ export class PortProxy {
domain = > domain . portRanges && domain . portRanges . length > 0 && isPortInRanges ( localPort , domain . portRanges )
) ;
if ( forcedDomain ) {
if ( ! isAllowed ( remoteIP , forcedDoma in. allowedIPs ) ) {
const effectiveAllowedIPs : str ing [ ] = [
. . . forcedDomain . allowedIPs ,
. . . ( this . settings . defaultAllowedIPs || [ ] )
] ;
const effectiveBlockedIPs : string [ ] = [
. . . ( forcedDomain . blockedIPs || [ ] ) ,
. . . ( this . settings . defaultBlockedIPs || [ ] )
] ;
if ( ! isGlobIPAllowed ( remoteIP , effectiveAllowedIPs , effectiveBlockedIPs ) ) {
console . log ( ` Connection from ${ remoteIP } rejected: IP not allowed for domain ${ forcedDomain . domains . join ( ', ' ) } on port ${ localPort } . ` ) ;
socket . end ( ) ;
return ;
@ -413,7 +488,7 @@ export class PortProxy {
listeningPorts . add ( port ) ;
}
}
// Also ensure the default fromPort is listened to if it isn’ t already in the ranges.
// 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 ) ;
@ -432,7 +507,7 @@ export class PortProxy {
this . netServers . push ( server ) ;
}
// Log active connection count and longest running durations every 10 seconds.
// Log active connection count, longest running durations, and run parity checks every 10 seconds.
this . connectionLogger = setInterval ( ( ) = > {
const now = Date . now ( ) ;
let maxIncoming = 0 ;
@ -442,6 +517,12 @@ export class PortProxy {
if ( record . outgoingStartTime ) {
maxOutgoing = Math . max ( maxOutgoing , now - record . outgoingStartTime ) ;
}
// Parity check: if outgoing socket closed and incoming remains active for >1 minute, trigger special cleanup.
if ( record . outgoingClosedTime && ! record . incoming . destroyed && ( now - record . outgoingClosedTime > 60000 ) ) {
const remoteIP = record . incoming . remoteAddress || 'unknown' ;
console . log ( ` Parity check triggered: Incoming socket for ${ remoteIP } has been active >1 minute after outgoing closed. ` ) ;
this . cleanupConnection ( record , true ) ;
}
}
console . log (
` (Interval Log) Active connections: ${ this . connectionRecords . size } . ` +
@ -466,28 +547,4 @@ export class PortProxy {
}
await Promise . all ( closePromises ) ;
}
}
// 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 ) )
) ;
} ;
}