@ -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,10 +17,12 @@ 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
forwardAllGlobalRanges? : boolean ; // When true, forwards all connections on global port ranges to the global targetIP
gracefulShutdownTimeout? : number ; // (ms) maximum time to wait for connections to close during shutdown
}
/**
@ -86,23 +89,61 @@ function extractSNI(buffer: Buffer): string | undefined {
}
interface IConnectionRecord {
id : string ; // Unique connection identifier
incoming : plugins.net.Socket ;
outgoing : plugins.net.Socket | null ;
incomingStartTime : number ;
outgoingStartTime? : 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
outgoingClosedTime? : number ;
lockedDomain? : string ; // Used to lock this connection to the initial SNI
connectionClosed : boolean ; // Flag to prevent multiple cleanup attempts
cleanupTimer? : NodeJS.Timeout ; // Timer for max lifetime/inactivity
lastActivity : number ; // Last activity timestamp for inactivity detection
}
// 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 ) ;
} ;
// Helper: Generate a unique connection ID
const generateConnectionId = ( ) : string = > {
return Math . random ( ) . toString ( 36 ) . substring ( 2 , 15 ) + Math . random ( ) . toString ( 36 ) . substring ( 2 , 15 ) ;
} ;
export class PortProxy {
private netServers : plugins.net.Server [ ] = [ ] ;
settings : IPortProxySettings ;
// Unified record tracking each connection pair.
private connectionRecords : Set < IConnectionRecord > = new Set ( ) ;
private connectionRecords : Map < string , IConnectionRecord > = new Map ( ) ;
private connectionLogger : NodeJS.Timeout | null = null ;
private isShuttingDown : boolean = false ;
// Map to track round robin indices for each domain config.
// Map to track round robin indices for each domain config
private domainTargetIndices : Map < IDomainConfig , number > = new Map ( ) ;
private terminationStats : {
@ -118,6 +159,7 @@ export class PortProxy {
. . . settingsArg ,
targetIP : settingsArg.targetIP || 'localhost' ,
maxConnectionLifetime : settingsArg.maxConnectionLifetime || 600000 ,
gracefulShutdownTimeout : settingsArg.gracefulShutdownTimeout || 30000 ,
} ;
}
@ -125,6 +167,67 @@ export class PortProxy {
this . terminationStats [ side ] [ reason ] = ( this . terminationStats [ side ] [ reason ] || 0 ) + 1 ;
}
/**
* Cleans up a connection record.
* Destroys both incoming and outgoing sockets, clears timers, and removes the record.
* @param record - The connection record to clean up
* @param reason - Optional reason for cleanup (for logging)
*/
private cleanupConnection ( record : IConnectionRecord , reason : string = 'normal' ) : void {
if ( ! record . connectionClosed ) {
record . connectionClosed = true ;
if ( record . cleanupTimer ) {
clearTimeout ( record . cleanupTimer ) ;
record . cleanupTimer = undefined ;
}
try {
if ( ! record . incoming . destroyed ) {
// Try graceful shutdown first, then force destroy after a short timeout
record . incoming . end ( ) ;
setTimeout ( ( ) = > {
if ( record && ! record . incoming . destroyed ) {
record . incoming . destroy ( ) ;
}
} , 1000 ) ;
}
} catch ( err ) {
console . log ( ` Error closing incoming socket: ${ err } ` ) ;
if ( ! record . incoming . destroyed ) {
record . incoming . destroy ( ) ;
}
}
try {
if ( record . outgoing && ! record . outgoing . destroyed ) {
// Try graceful shutdown first, then force destroy after a short timeout
record . outgoing . end ( ) ;
setTimeout ( ( ) = > {
if ( record && record . outgoing && ! record . outgoing . destroyed ) {
record . outgoing . destroy ( ) ;
}
} , 1000 ) ;
}
} catch ( err ) {
console . log ( ` Error closing outgoing socket: ${ err } ` ) ;
if ( record . outgoing && ! record . outgoing . destroyed ) {
record . outgoing . destroy ( ) ;
}
}
// Remove the record from the tracking map
this . connectionRecords . delete ( record . id ) ;
const remoteIP = record . incoming . remoteAddress || 'unknown' ;
console . log ( ` Connection from ${ remoteIP } terminated ( ${ reason } ). Active connections: ${ this . connectionRecords . size } ` ) ;
}
}
private updateActivity ( record : IConnectionRecord ) : void {
record . lastActivity = Date . now ( ) ;
}
private getTargetIP ( domainConfig : IDomainConfig ) : string {
if ( domainConfig . targetIPs && domainConfig . targetIPs . length > 0 ) {
const currentIndex = this . domainTargetIndices . get ( domainConfig ) || 0 ;
@ -138,36 +241,44 @@ export class PortProxy {
public async start() {
// Define a unified connection handler for all listening ports.
const connectionHandler = ( socket : plugins.net.Socket ) = > {
if ( this . isShuttingDown ) {
socket . end ( ) ;
socket . destroy ( ) ;
return ;
}
const remoteIP = socket . remoteAddress || '' ;
const localPort = socket . localPort ; // The port on which this connection was accepted.
const connectionId = generateConnectionId ( ) ;
const connectionRecord : IConnectionRecord = {
id : connectionId ,
incoming : socket ,
outgoing : null ,
incomingStartTime : Date.now ( ) ,
connectionClosed : false ,
lastActivity : Date.now ( ) ,
connectionClosed : false
} ;
this . connectionRecords . add ( connectionRecord ) ;
this . connectionRecords . set ( connectionId , connectionRecord ) ;
console . log ( ` New connection from ${ remoteIP } on port ${ localPort } . Active connections: ${ this . connectionRecords . size } ` ) ;
let initialDataReceived = false ;
let incomingTerminationReason : string | null = null ;
let outgoingTerminationReason : string | null = null ;
// Ensure cleanup happens only once for the entire connection record.
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 function for cleanupOnce
const cleanupOnce = ( ) = > {
this . cleanupC onnection( connectionRecor d ) ;
} ;
// Define initiateCleanupOnce for compatibility with potential future improvements
const initiateCleanupOnce = ( reason : string = 'normal' ) = > {
console . log ( ` Connection cleanup initiated for ${ remoteIP } ( ${ reason } ) ` ) ;
cleanupOnce ( ) ;
} ;
// Helper to reject an incoming connection.
// Helper to reject an incoming connection
const rejectIncomingConnection = ( reason : string , logMessage : string ) = > {
console . log ( logMessage ) ;
socket . end ( ) ;
@ -178,11 +289,22 @@ export class PortProxy {
cleanupOnce ( ) ;
} ;
// Set an initial timeout for SNI data if needed
let initialTimeout : NodeJS.Timeout | null = null ;
if ( this . settings . sniEnabled ) {
initialTimeout = setTimeout ( ( ) = > {
if ( ! initialDataReceived ) {
console . log ( ` Initial data timeout for ${ remoteIP } ` ) ;
socket . end ( ) ;
cleanupOnce ( ) ;
}
} , 5000 ) ;
} else {
initialDataReceived = true ;
}
socket . on ( 'error' , ( err : Error ) = > {
const errorMessage = initialDataReceived
? ` (Immediate) Incoming socket error from ${ remoteIP } : ${ err . message } `
: ` (Premature) Incoming socket error from ${ remoteIP } before data received: ${ err . message } ` ;
console . log ( errorMessage ) ;
console . log ( ` Incoming socket error from ${ remoteIP } : ${ err . message } ` ) ;
} ) ;
const handleError = ( side : 'incoming' | 'outgoing' ) = > ( err : Error ) = > {
@ -201,7 +323,7 @@ export class PortProxy {
outgoingTerminationReason = reason ;
this . incrementTerminationStat ( 'outgoing' , reason ) ;
}
c leanupOnce( ) ;
initiateC leanupOnce( reason ) ;
} ;
const handleClose = ( side : 'incoming' | 'outgoing' ) = > ( ) = > {
@ -212,8 +334,10 @@ 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 ( ) ;
}
c leanupOnce( ) ;
initiateC leanupOnce( 'closed_' + side ) ;
} ;
/**
@ -221,9 +345,15 @@ export class PortProxy {
* @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).
* @param overridePort - If provided, use this port for the outgoing connection (typically the same as the incoming port) .
* @param overridePort - If provided, use this port for the outgoing connection.
*/
const setupConnection = ( serverName : string , initialChunk? : Buffer , forcedDomain? : IDomainConfig , overridePort? : number ) = > {
// Clear the initial timeout since we've received data
if ( initialTimeout ) {
clearTimeout ( initialTimeout ) ;
initialTimeout = null ;
}
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
const domainConfig = forcedDomain
? forcedDomain
@ -231,17 +361,27 @@ export class PortProxy {
config . domains . some ( d = > plugins . minimatch ( serverName , d ) )
) : undefined ) ;
// If a matching domain config exists, check its allowedIPs.
// IP validation is skipped if allowedIPs is empty
if ( domainConfig ) {
if ( ! isAllowed ( remoteIP , domainConfig . allowedIPs ) ) {
const effectiveAllowedIPs : string [ ] = [
. . . domainConfig . allowedIPs ,
. . . ( this . settings . defaultAllowedIPs || [ ] )
] ;
const effectiveBlockedIPs : string [ ] = [
. . . ( domainConfig . blockedIPs || [ ] ) ,
. . . ( this . settings . defaultBlockedIPs || [ ] )
] ;
// Skip IP validation if allowedIPs is empty
if ( domainConfig . allowedIPs . length > 0 && ! 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 ) ) {
} else if ( this . settings . defaultAllowedIPs && this . settings . defaultAllowedIPs . length > 0 ) {
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 ,
@ -251,23 +391,25 @@ export class PortProxy {
connectionOptions . localAddress = remoteIP . replace ( '::ffff:' , '' ) ;
}
// Create the target socket and immediately set up data piping
const targetSocket = plugins . net . connect ( connectionOptions ) ;
connectionRecord . outgoing = targetSocket ;
connectionRecord . outgoingStartTime = Date . now ( ) ;
// Set up the pipe immediately to ensure data flows without delay
if ( initialChunk ) {
socket . unshift ( initialChunk ) ;
}
socket . pipe ( targetSocket ) ;
targetSocket . pipe ( socket ) ;
console . log (
` Connection established: ${ remoteIP } -> ${ targetHost } : ${ connectionOptions . port } ` +
` ${ serverName ? ` (SNI: ${ serverName } ) ` : forcedDomain ? ` (Port-based for domain: ${ forcedDomain . domains . join ( ', ' ) } ) ` : '' } `
) ;
if ( initialChunk ) {
socket . unshift ( initialChunk ) ;
}
socket . setTimeout ( 120000 ) ;
socket . pipe ( targetSocket ) ;
targetSocket . pipe ( socket ) ;
// Attach error and close handlers.
// Add appropriate handlers for connection management
socket . on ( 'error' , handleError ( 'incoming' ) ) ;
targetSocket . on ( 'error' , handleError ( 'outgoing' ) ) ;
socket . on ( 'close' , handleClose ( 'incoming' ) ) ;
@ -278,7 +420,7 @@ export class PortProxy {
incomingTerminationReason = 'timeout' ;
this . incrementTerminationStat ( 'incoming' , 'timeout' ) ;
}
c leanupOnce( ) ;
initiateC leanupOnce( 'timeout_incoming' ) ;
} ) ;
targetSocket . on ( 'timeout' , ( ) = > {
console . log ( ` Timeout on outgoing side from ${ remoteIP } ` ) ;
@ -286,45 +428,28 @@ export class PortProxy {
outgoingTerminationReason = 'timeout' ;
this . incrementTerminationStat ( 'outgoing' , 'timeout' ) ;
}
c leanupOnce( ) ;
initiateC leanupOnce( 'timeout_outgoing' ) ;
} ) ;
socket . on ( 'end' , handleClose ( 'incoming' ) ) ;
targetSocket . on ( 'end' , handleClose ( 'outgoing' ) ) ;
// Initialize a cleanup timer for max connection lifetime.
// Set appropriate timeouts
socket . setTimeout ( 120000 ) ;
targetSocket . setTimeout ( 120000 ) ;
// Update activity for both sockets
socket . on ( 'data' , ( ) = > {
connectionRecord . lastActivity = Date . now ( ) ;
} ) ;
targetSocket . on ( 'data' , ( ) = > {
connectionRecord . lastActivity = Date . now ( ) ;
} ) ;
// Initialize a cleanup timer for max connection lifetime
if ( this . settings . maxConnectionLifetime ) {
let incomingActive = false ;
let outgoingAc tiv e = false ;
const resetCleanupTimer = ( ) = > {
if ( this . settings . maxConnectionLifetime ) {
if ( connectionRecord . cleanupTimer ) {
clearTimeout ( connectionRecord . cleanupTimer ) ;
}
connectionRecord . cleanupTimer = setTimeout ( ( ) = > {
console . log ( ` Connection from ${ remoteIP } exceeded max lifetime with inactivity ( ${ this . settings . maxConnectionLifetime } ms), forcing cleanup. ` ) ;
cleanupOnce ( ) ;
} , this . settings . maxConnectionLifetime ) ;
}
} ;
resetCleanupTimer ( ) ;
socket . on ( 'data' , ( ) = > {
incomingActive = true ;
if ( incomingActive && outgoingActive ) {
resetCleanupTimer ( ) ;
incomingActive = false ;
outgoingActive = false ;
}
} ) ;
targetSocket . on ( 'data' , ( ) = > {
outgoingActive = true ;
if ( incomingActive && outgoingActive ) {
resetCleanupTimer ( ) ;
incomingActive = false ;
outgoingActive = false ;
}
} ) ;
connectionRecord . cleanupTimer = setTimeout ( ( ) = > {
console . log ( ` Connection from ${ remoteIP } exceeded max lifetime ( ${ this . settings . maxConnectionLife tim e } ms), forcing cleanup. ` ) ;
initiateCleanupOnce ( 'max_lifetime' ) ;
} , this . settings . maxConnectionLifetime ) ;
}
} ;
@ -341,6 +466,7 @@ export class PortProxy {
setupConnection ( '' , undefined , {
domains : [ 'global' ] ,
allowedIPs : this.settings.defaultAllowedIPs || [ ] ,
blockedIPs : this.settings.defaultBlockedIPs || [ ] ,
targetIPs : [ this . settings . targetIP ! ] ,
portRanges : [ ]
} , localPort ) ;
@ -351,7 +477,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 ;
@ -366,37 +500,46 @@ export class PortProxy {
// --- FALLBACK: SNI-BASED HANDLING (or default when SNI is disabled) ---
if ( this . settings . sniEnabled ) {
socket . setTimeout ( 5000 , ( ) = > {
console . log ( ` Initial data timeout for ${ remoteIP } ` ) ;
socket . end ( ) ;
cleanupOnce ( ) ;
} ) ;
initialDataReceived = false ;
socket . once ( 'data' , ( chunk : Buffer ) = > {
socket . setTimeout ( 0 ) ;
if ( initialTimeout ) {
clearTimeout ( initialTimeout ) ;
initialTimeout = null ;
}
initialDataReceived = true ;
const serverName = extractSNI ( chunk ) || '' ;
// Lock the connection to the negotiated SNI.
connectionRecord . lockedDomain = serverName ;
console . log ( ` Received connection from ${ remoteIP } with SNI: ${ serverName } ` ) ;
// Delay adding the renegotiation listener until the next tick,
// so the initial ClientHello is not reprocessed.
setImmediate ( ( ) = > {
socket . on ( 'data' , ( renegChunk : Buffer ) = > {
if ( renegChunk . length > 0 && renegChunk . readUInt8 ( 0 ) === 22 ) {
const newSNI = extractSNI ( renegChunk ) ;
if ( newSNI && newSNI !== connectionRecord . lockedDomain ) {
console . log ( ` Rehandshake detected with different SNI: ${ newSNI } vs locked ${ connectionRecord . lockedDomain } . Terminating connection. ` ) ;
cleanupOnce ( ) ;
try {
// Try to extract SNI from potential renegotiation
const newSNI = extractSNI ( renegChunk ) ;
if ( newSNI && newSNI !== connectionRecord . lockedDomain ) {
console . log ( ` Rehandshake detected with different SNI: ${ newSNI } vs locked ${ connectionRecord . lockedDomain } . Terminating connection. ` ) ;
initiateCleanupOnce ( 'sni_mismatch' ) ;
} else if ( newSNI ) {
console . log ( ` Rehandshake detected with same SNI: ${ newSNI } . Allowing. ` ) ;
}
} catch ( err ) {
console . log ( ` Error processing potential renegotiation: ${ err } . Allowing connection to continue. ` ) ;
}
}
} ) ;
} ) ;
setupConnection ( serverName , chunk ) ;
} ) ;
} else {
initialDataReceived = true ;
if ( ! this . settings . defaultAllowedIPs || ! isAllowed ( remoteIP , this . settings . defaultAllowedIPs ) ) {
if ( ! this . settings . defaultAllowedIPs || this . settings . defaultAllowedIPs . length === 0 || ! isAllowed ( remoteIP , this . settings . defaultAllowedIPs ) ) {
return rejectIncomingConnection ( 'rejected' , ` Connection rejected: IP ${ remoteIP } not allowed for non-SNI connection ` ) ;
}
setupConnection ( '' ) ;
@ -413,7 +556,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,17 +575,46 @@ 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 ( ( ) = > {
if ( this . isShuttingDown ) return ;
const now = Date . now ( ) ;
let maxIncoming = 0 ;
let maxOutgoing = 0 ;
for ( const record of this . connectionRecords ) {
// Create a copy of the keys to avoid modification during iteration
const connectionIds = [ . . . this . connectionRecords . keys ( ) ] ;
for ( const id of connectionIds ) {
const record = this . connectionRecords . get ( id ) ;
if ( ! record ) continue ;
maxIncoming = Math . max ( maxIncoming , now - record . incomingStartTime ) ;
if ( record . outgoingStartTime ) {
maxOutgoing = Math . max ( maxOutgoing , now - record . outgoingStartTime ) ;
}
// Parity check: if outgoing socket closed and incoming remains active
if ( record . outgoingClosedTime &&
! record . incoming . destroyed &&
! record . connectionClosed &&
( now - record . outgoingClosedTime > 30000 ) ) {
const remoteIP = record . incoming . remoteAddress || 'unknown' ;
console . log ( ` Parity check: Incoming socket for ${ remoteIP } still active ${ plugins . prettyMs ( now - record . outgoingClosedTime ) } after outgoing closed. ` ) ;
this . cleanupConnection ( record , 'parity_check' ) ;
}
// Inactivity check
const inactivityTime = now - record . lastActivity ;
if ( inactivityTime > 180000 && // 3 minutes
! record . connectionClosed ) {
const remoteIP = record . incoming . remoteAddress || 'unknown' ;
console . log ( ` Inactivity check: No activity on connection from ${ remoteIP } for ${ plugins . prettyMs ( inactivityTime ) } . ` ) ;
this . cleanupConnection ( record , 'inactivity' ) ;
}
}
console . log (
` (Interval Log) Active connections: ${ this . connectionRecords . size } . ` +
` Longest running incoming: ${ plugins . prettyMs ( maxIncoming ) } , outgoing: ${ plugins . prettyMs ( maxOutgoing ) } . ` +
@ -453,41 +625,69 @@ export class PortProxy {
}
public async stop() {
// Close all servers.
const closePromises : Promise < void > [ ] = this . netServers . map (
console . log ( "PortProxy shutting down..." ) ;
this . isShuttingDown = true ;
// Stop accepting new connections
const closeServerPromises : Promise < void > [ ] = this . netServers . map (
server = >
new Promise < void > ( ( resolve ) = > {
server . close ( ( ) = > resolve ( ) ) ;
} )
) ;
// Stop the connection logger
if ( this . connectionLogger ) {
clearInterval ( this . connectionLogger ) ;
this . connectionLogger = null ;
}
await Promise . all ( closePromises ) ;
// Wait for servers to close
await Promise . all ( closeServerPromises ) ;
console . log ( "All servers closed. Cleaning up active connections..." ) ;
// Clean up active connections
const connectionIds = [ . . . this . connectionRecords . keys ( ) ] ;
console . log ( ` Cleaning up ${ connectionIds . length } active connections... ` ) ;
for ( const id of connectionIds ) {
const record = this . connectionRecords . get ( id ) ;
if ( record && ! record . connectionClosed ) {
this . cleanupConnection ( record , 'shutdown' ) ;
}
}
// Wait for graceful shutdown or timeout
const shutdownTimeout = this . settings . gracefulShutdownTimeout || 30000 ;
await new Promise < void > ( ( resolve ) = > {
const checkInterval = setInterval ( ( ) = > {
if ( this . connectionRecords . size === 0 ) {
clearInterval ( checkInterval ) ;
resolve ( ) ;
}
} , 1000 ) ;
// Force resolve after timeout
setTimeout ( ( ) = > {
clearInterval ( checkInterval ) ;
if ( this . connectionRecords . size > 0 ) {
console . log ( ` Forcing shutdown with ${ this . connectionRecords . size } connections still active ` ) ;
// Force destroy any remaining connections
for ( const record of this . connectionRecords . values ( ) ) {
if ( ! record . incoming . destroyed ) {
record . incoming . destroy ( ) ;
}
if ( record . outgoing && ! record . outgoing . destroyed ) {
record . outgoing . destroy ( ) ;
}
}
this . connectionRecords . clear ( ) ;
}
resolve ( ) ;
} , shutdownTimeout ) ;
} ) ;
console . log ( "PortProxy shutdown complete." ) ;
}
}
// 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 ) )
) ;
} ;
}