@ -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,13 @@ 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
initialDataTimeout? : number ; // (ms) timeout for receiving initial data, useful for chained proxies
}
/**
@ -90,17 +94,56 @@ interface IConnectionRecord {
outgoing : plugins.net.Socket | null ;
incomingStartTime : number ;
outgoingStartTime? : number ;
lockedDomain? : string ; // New field to lock this connection to the initial SNI
outgoingClosedTime? : number ;
lockedDomain? : string ; // Field to lock this connection to the initial SNI
connectionClosed : boolean ;
cleanupTimer? : NodeJS.Timeout ; // Timer to force cleanup after max lifetime/inactivity
id : string ; // Unique identifier for the connection
lastActivity : number ; // Timestamp of last activity on either socket
}
// 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 ID for a connection
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.
private domainTargetIndices : Map < IDomainConfig , number > = new Map ( ) ;
@ -118,13 +161,40 @@ export class PortProxy {
. . . settingsArg ,
targetIP : settingsArg.targetIP || 'localhost' ,
maxConnectionLifetime : settingsArg.maxConnectionLifetime || 600000 ,
gracefulShutdownTimeout : settingsArg.gracefulShutdownTimeout || 30000 ,
} ;
// Debug logging for constructor settings
console . log ( ` PortProxy initialized with targetIP: ${ this . settings . targetIP } , toPort: ${ this . settings . toPort } , fromPort: ${ this . settings . fromPort } , sniEnabled: ${ this . settings . sniEnabled } ` ) ;
}
private incrementTerminationStat ( side : 'incoming' | 'outgoing' , reason : string ) : void {
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 , reason : string = 'normal' ) : 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 . id ) ;
const remoteIP = record . incoming . remoteAddress || 'unknown' ;
console . log ( ` Connection from ${ remoteIP } terminated ( ${ reason } ). Active connections: ${ this . connectionRecords . size } ` ) ;
}
}
private getTargetIP ( domainConfig : IDomainConfig ) : string {
if ( domainConfig . targetIPs && domainConfig . targetIPs . length > 0 ) {
const currentIndex = this . domainTargetIndices . get ( domainConfig ) || 0 ;
@ -135,39 +205,45 @@ export class PortProxy {
return this . settings . targetIP ! ;
}
private updateActivity ( record : IConnectionRecord ) : void {
record . lastActivity = Date . now ( ) ;
}
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 ( ) ,
lastActivity : Date.now ( ) ,
connectionClosed : false ,
cleanupInitiated : false
} ;
this . connectionRecords . add ( connectionRecord ) ;
console . log ( ` New connection from ${ remoteIP } on port ${ localPort } . Active connections: ${ this . connectionRecords . size } ` ) ;
this . connectionRecords . set ( connectionId , connectionRecord ) ;
console . log ( ` New connection ${ connectionId } 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 recor d.
const c leanupOnce = async ( ) = > {
if ( ! connectionRecord . connectionClosed ) {
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 initiateC leanupOnce = (reason : string = 'normal' ) = > {
this . initiateCleanup ( connectionRecord , reason ) ;
} ;
// Helper to reject an incoming connection.
const rejectIncomingConnection = ( reason : string , logMessage : string ) = > {
console . log ( logMessage ) ;
socket . end ( ) ;
@ -178,6 +254,46 @@ export class PortProxy {
cleanupOnce ( ) ;
} ;
// IMPORTANT: We won't set any initial timeout for a chained proxy scenario
// The code below is commented out to restore original behavior
/*
let initialTimeout: NodeJS.Timeout | null = null;
const initialTimeoutMs = this.settings.initialDataTimeout ||
(this.settings.sniEnabled ? 15000 : 0);
if (initialTimeoutMs > 0) {
console.log(`Setting initial data timeout of ${initialTimeoutMs}ms for connection from ${remoteIP}`);
initialTimeout = setTimeout(() => {
if (!initialDataReceived) {
console.log(`Initial connection timeout for ${remoteIP} (no data received after ${initialTimeoutMs}ms)`);
if (incomingTerminationReason === null) {
incomingTerminationReason = 'initial_timeout';
this.incrementTerminationStat('incoming', 'initial_timeout');
}
initiateCleanupOnce('initial_timeout');
}
}, initialTimeoutMs);
} else {
console.log(`No initial timeout set for connection from ${remoteIP} (likely chained proxy)`);
initialDataReceived = true;
}
*/
// Original behavior: only set timeout if SNI is enabled, and use a fixed 5 second timeout
let initialTimeout : NodeJS.Timeout | null = null ;
if ( this . settings . sniEnabled ) {
console . log ( ` Setting 5 second initial timeout for SNI extraction from ${ remoteIP } ` ) ;
initialTimeout = setTimeout ( ( ) = > {
if ( ! initialDataReceived ) {
console . log ( ` Initial data timeout for ${ remoteIP } ` ) ;
socket . end ( ) ;
initiateCleanupOnce ( 'initial_timeout' ) ;
}
} , 5000 ) ;
} else {
initialDataReceived = true ;
}
socket . on ( 'error' , ( err : Error ) = > {
const errorMessage = initialDataReceived
? ` (Immediate) Incoming socket error from ${ remoteIP } : ${ err . message } `
@ -212,6 +328,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 ( ) ;
} ;
@ -221,9 +339,14 @@ 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 ) ;
}
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
const domainConfig = forcedDomain
? forcedDomain
@ -231,17 +354,30 @@ 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.
// Use original domain configuration and IP validation logic
// This restores the behavior that was working before
if ( domainConfig ) {
if ( ! isAllowed ( remoteIP , domainConfig . allowedIPs ) ) {
const effectiveAllowedIPs : string [ ] = [
. . . domainConfig . allowedIPs ,
. . . ( this . settings . defaultAllowedIPs || [ ] )
] ;
const effectiveBlockedIPs : string [ ] = [
. . . ( domainConfig . blockedIPs || [ ] ) ,
. . . ( this . settings . defaultBlockedIPs || [ ] )
] ;
// Special case: if allowedIPs is empty, skip IP validation for backward compatibility
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 ` ) ;
}
}
}
// If no IP validation rules, allow the connection (original behavior)
const targetHost = domainConfig ? this . getTargetIP ( domainConfig ) : this . settings . targetIP ! ;
const connectionOptions : plugins.net.NetConnectOpts = {
host : targetHost ,
@ -251,80 +387,124 @@ export class PortProxy {
connectionOptions . localAddress = remoteIP . replace ( '::ffff:' , '' ) ;
}
// Add explicit connection timeout and error handling
let connectionTimeout : NodeJS.Timeout | null = null ;
let connectionSucceeded = false ;
// Set connection timeout - longer for chained proxies
connectionTimeout = setTimeout ( ( ) = > {
if ( ! connectionSucceeded ) {
console . log ( ` Connection timeout connecting to ${ targetHost } : ${ connectionOptions . port } for ${ remoteIP } ` ) ;
if ( outgoingTerminationReason === null ) {
outgoingTerminationReason = 'connection_timeout' ;
this . incrementTerminationStat ( 'outgoing' , 'connection_timeout' ) ;
}
initiateCleanupOnce ( 'connection_timeout' ) ;
}
} , 10000 ) ; // Increased from 5s to 10s to accommodate chained proxies
console . log ( ` Attempting to connect to ${ targetHost } : ${ connectionOptions . port } for client ${ remoteIP } ... ` ) ;
// Create the target socket
const targetSocket = plugins . net . connect ( connectionOptions ) ;
connectionRecord . outgoing = targetSocket ;
connectionRecord . outgoingStartTime = Date . now ( ) ;
// Handle successful connection
targetSocket . once ( 'connect' , ( ) = > {
connectionSucceeded = true ;
if ( connectionTimeout ) {
clearTimeout ( connectionTimeout ) ;
connectionTimeout = null ;
}
connectionRecord . outgoingStartTime = Date . now ( ) ;
console . log (
` Connection established: ${ remoteIP } -> ${ targetHost } : ${ connectionOptions . port } ` +
` ${ serverName ? ` (SNI: ${ serverName } ) ` : forcedDomain ? ` (Port-based for domain: ${ forcedDomain . domains . join ( ', ' ) } ) ` : '' } `
) ;
console . log (
` Connection established: ${ remoteIP } -> ${ targetHost } : ${ connectionOptions . port } ` +
` ${ serverName ? ` (SNI: ${ serverName } )` : forcedDomain ? ` (Port-based for domain: ${ forcedDomain . domains . join ( ', ' ) } ) ` : '' } `
) ;
// Setup data flow after confirmed connection
setupDataFlow ( targetSocket , initialChunk ) ;
} );
// Handle connection errors early
targetSocket . once ( 'error' , ( err ) = > {
if ( ! connectionSucceeded ) {
// This is an initial connection error
console . log ( ` Failed to connect to ${ targetHost } : ${ connectionOptions . port } for ${ remoteIP } : ${ err . message } ` ) ;
if ( connectionTimeout ) {
clearTimeout ( connectionTimeout ) ;
connectionTimeout = null ;
}
if ( outgoingTerminationReason === null ) {
outgoingTerminationReason = 'connection_failed' ;
this . incrementTerminationStat ( 'outgoing' , 'connection_failed' ) ;
}
initiateCleanupOnce ( 'connection_failed' ) ;
}
// Other errors will be handled by the main error handler
} ) ;
} ;
/**
* Sets up the data flow between sockets after successful connection
*/
const setupDataFlow = ( targetSocket : plugins.net.Socket , initialChunk? : Buffer ) = > {
if ( initialChunk ) {
socket . unshift ( initialChunk ) ;
}
// Set appropriate timeouts for both sockets
socket . setTimeout ( 120000 ) ;
targetSocket . setTimeout ( 120000 ) ;
// Set up the pipe in both directions
socket . pipe ( targetSocket ) ;
targetSocket . pipe ( socket ) ;
// Attach error and close handlers.
// Attach error and close handlers
socket . on ( 'error' , handleError ( 'incoming' ) ) ;
targetSocket . on ( 'error' , handleError ( 'outgoing' ) ) ;
socket . on ( 'close' , handleClose ( 'incoming' ) ) ;
targetSocket . on ( 'close' , handleClose ( 'outgoing' ) ) ;
// Handle timeout events
socket . on ( 'timeout' , ( ) = > {
console . log ( ` Timeout on incoming side from ${ remoteIP } ` ) ;
if ( incomingTerminationReason === null ) {
incomingTerminationReason = 'timeout' ;
this . incrementTerminationStat ( 'incoming' , 'timeout' ) ;
}
c leanupOnce( ) ;
initiateC leanupOnce( 'timeout' ) ;
} ) ;
targetSocket . on ( 'timeout' , ( ) = > {
console . log ( ` Timeout on outgoing side from ${ remoteIP } ` ) ;
if ( outgoingTerminationReason === null ) {
outgoingTerminationReason = 'timeout' ;
this . incrementTerminationStat ( 'outgoing' , 'timeout' ) ;
}
c leanupOnce( ) ;
initiateC leanupOnce( 'timeout' ) ;
} ) ;
socket . on ( 'end' , handleClose ( 'incoming' ) ) ;
targetSocket . on ( 'end' , handleClose ( 'outgoing' ) ) ;
// Initialize a cleanup timer for max connection life time.
// Track activity for both sockets to reset inactivity timers
socket . on ( 'data' , ( data ) = > {
this . updateActivity ( connectionRecord ) ;
} ) ;
targetSocket . on ( 'data' , ( data ) = > {
this . updateActivity ( connectionRecord ) ;
} ) ;
// 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 ) ;
}
} ;
@ -335,12 +515,14 @@ export class PortProxy {
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 ( ) ;
initiateCleanupOnce ( 'rejected' ) ;
return ;
}
console . log ( ` Port-based connection from ${ remoteIP } on port ${ localPort } forwarded to global target IP ${ this . settings . targetIP } . ` ) ;
setupConnection ( '' , undefined , {
domains : [ 'global' ] ,
allowedIPs : this.settings.defaultAllowedIPs || [ ] ,
blockedIPs : this.settings.defaultBlockedIPs || [ ] ,
targetIPs : [ this . settings . targetIP ! ] ,
portRanges : [ ]
} , localPort ) ;
@ -351,9 +533,18 @@ 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 ( ) ;
initiateCleanupOnce ( 'rejected' ) ;
return ;
}
console . log ( ` Port-based connection from ${ remoteIP } on port ${ localPort } matched domain ${ forcedDomain . domains . join ( ', ' ) } . ` ) ;
@ -366,37 +557,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 +613,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 +632,48 @@ export class PortProxy {
this . netServers . push ( server ) ;
}
// Log active connection count and longest running duration s every 10 seconds.
// Log active connection count, run parity checks, and check for connection issue s 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 for >30 seconds, trigger cleanup
if ( record . outgoingClosedTime &&
! record . incoming . destroyed &&
! record . connectionClosed &&
! record . cleanupInitiated &&
( now - record . outgoingClosedTime > 30000 ) ) {
const remoteIP = record . incoming . remoteAddress || 'unknown' ;
console . log ( ` Parity check triggered: Incoming socket for ${ remoteIP } has been active >30s after outgoing closed. ` ) ;
this . initiateCleanup ( record , 'parity_check' ) ;
}
// Inactivity check: if no activity for a long time but sockets still open
const inactivityTime = now - record . lastActivity ;
if ( inactivityTime > 180000 && // 3 minutes
! record . connectionClosed &&
! record . cleanupInitiated ) {
const remoteIP = record . incoming . remoteAddress || 'unknown' ;
console . log ( ` Inactivity check triggered: No activity on connection from ${ remoteIP } for ${ plugins . prettyMs ( inactivityTime ) } . ` ) ;
this . initiateCleanup ( record , 'inactivity' ) ;
}
}
console . log (
` (Interval Log) Active connections: ${ this . connectionRecords . size } . ` +
` Longest running incoming: ${ plugins . prettyMs ( maxIncoming ) } , outgoing: ${ plugins . prettyMs ( maxOutgoing ) } . ` +
@ -453,41 +684,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..." ) ;
// Gracefully close 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 && ! record . cleanupInitiated ) {
this . initiateCleanup ( 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 ) )
) ;
} ;
}