Compare commits

...

22 Commits

Author SHA1 Message Date
677d30563f 4.1.0
Some checks failed
Default (tags) / security (push) Successful in 37s
Default (tags) / test (push) Failing after 59s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-14 11:34:53 +00:00
9aa747b5d4 feat(SniHandler): Enhance SNI extraction to support session caching and tab reactivation by adding session cache initialization, cleanup and helper methods. Update processTlsPacket to use cached SNI for session resumption and connection racing scenarios. 2025-03-14 11:34:52 +00:00
1de9491e1d 4.0.0
Some checks failed
Default (tags) / security (push) Successful in 35s
Default (tags) / test (push) Failing after 1m6s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-14 09:53:25 +00:00
e2ee673197 BREAKING CHANGE(core): refactor: reorganize internal module structure to use classes.pp.* modules
- Renamed port proxy and SNI handler source files to classes.pp.portproxy.js and classes.pp.snihandler.js respectively
- Updated import paths in index.ts and test files (e.g. in test.ts and test.router.ts) to reference the new file names
- This refactor improves code organization but breaks direct imports from the old paths
2025-03-14 09:53:25 +00:00
985031e9ac 3.41.8
Some checks failed
Default (tags) / security (push) Successful in 37s
Default (tags) / test (push) Failing after 1m8s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-12 15:49:42 +00:00
4c0105ad09 fix(portproxy): Improve TLS handshake timeout handling and connection piping in PortProxy 2025-03-12 15:49:41 +00:00
06896b3102 3.41.7
Some checks failed
Default (tags) / security (push) Successful in 35s
Default (tags) / test (push) Failing after 1m0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-12 12:19:36 +00:00
7fe455b4df fix(core): Refactor PortProxy and SniHandler: improve configuration handling, logging, and whitespace consistency 2025-03-12 12:19:36 +00:00
21801aa53d 3.41.6
Some checks failed
Default (tags) / security (push) Successful in 37s
Default (tags) / test (push) Failing after 1m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-12 10:54:24 +00:00
ddfbcdb1f3 fix(SniHandler): Refactor SniHandler: update whitespace, comment formatting, and consistent type definitions 2025-03-12 10:54:24 +00:00
b401d126bc 3.41.5
Some checks failed
Default (tags) / security (push) Successful in 35s
Default (tags) / test (push) Failing after 1m6s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-12 10:27:26 +00:00
baaee0ad4d fix(portproxy): Enforce TLS handshake and SNI validation on port 443 by blocking non-TLS connections and terminating session resumption attempts without SNI when allowSessionTicket is disabled. 2025-03-12 10:27:25 +00:00
fe7c4c2f5e 3.41.4
Some checks failed
Default (tags) / security (push) Successful in 30s
Default (tags) / test (push) Failing after 1m0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-12 10:01:54 +00:00
ab1ec84832 fix(tls/sni): Improve logging for TLS session resumption by extracting and logging SNI values from ClientHello messages. 2025-03-12 10:01:54 +00:00
156abbf5b4 3.41.3
Some checks failed
Default (tags) / security (push) Failing after 10m42s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-03-12 09:56:21 +00:00
1a90566622 fix(TLS/SNI): Improve TLS session resumption handling and logging. Now, session resumption attempts are always logged with details, and connections without a proper SNI are rejected when allowSessionTicket is disabled. In addition, empty SNI extensions are explicitly treated as missing, ensuring stricter and more consistent TLS handshake validation. 2025-03-12 09:56:21 +00:00
b48b90d613 3.41.2
Some checks failed
Default (tags) / security (push) Successful in 28s
Default (tags) / test (push) Failing after 1m10s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-11 19:41:04 +00:00
124f8d48b7 fix(SniHandler): Refactor hasSessionResumption to return detailed session resumption info 2025-03-11 19:41:04 +00:00
b2a57ada5d 3.41.1
Some checks failed
Default (tags) / security (push) Successful in 30s
Default (tags) / test (push) Failing after 1m12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-11 19:38:41 +00:00
62a3e1f4b7 fix(SniHandler): Improve TLS SNI session resumption handling: connections containing a session ticket are now only rejected when no SNI is present and allowSessionTicket is disabled. Updated return values and logging for clearer resumption detection. 2025-03-11 19:38:41 +00:00
3a1485213a 3.41.0
Some checks failed
Default (tags) / security (push) Failing after 10m42s
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2025-03-11 19:31:20 +00:00
9dbf6fdeb5 feat(PortProxy/TLS): Add allowSessionTicket option to control TLS session ticket handling 2025-03-11 19:31:20 +00:00
19 changed files with 3852 additions and 2477 deletions

View File

@ -1,5 +1,90 @@
# Changelog
## 2025-03-14 - 4.1.0 - feat(SniHandler)
Enhance SNI extraction to support session caching and tab reactivation by adding session cache initialization, cleanup and helper methods. Update processTlsPacket to use cached SNI for session resumption and connection racing scenarios.
- Introduce initSessionCacheCleanup, cleanupSessionCache, createClientKey, cacheSession, and getCachedSession methods to manage SNI information.
- Cache SNI based on client IP and client random to improve handling of fragmented ClientHello messages and tab reactivation.
- Update processTlsPacket to leverage cached SNI when standard extraction fails, reducing redundant extraction and enhancing connection racing behavior.
## 2025-03-14 - 4.0.0 - BREAKING CHANGE(core)
refactor: reorganize internal module structure to use 'classes.pp.*' modules
- Renamed port proxy and SNI handler source files to 'classes.pp.portproxy.js' and 'classes.pp.snihandler.js' respectively
- Updated import paths in index.ts and test files (e.g. in test.ts and test.router.ts) to reference the new file names
- This refactor improves code organization but breaks direct imports from the old paths
- Renamed 'ts/classes.portproxy.ts' to 'ts/classes.pp.portproxy.ts'
- Renamed 'ts/classes.snihandler.ts' to 'ts/classes.pp.snihandler.ts'
- Updated exports in index.ts to export from 'classes.pp.portproxy.js' and 'classes.pp.snihandler.js'
- Updated test files to import modules from new paths
## 2025-03-12 - 3.41.8 - fix(portproxy)
Improve TLS handshake timeout handling and connection piping in PortProxy
- Increase the default initial handshake timeout from 60 seconds to 120 seconds
- Add a 30-second grace period before terminating connections waiting for initial TLS data
- Refactor piping logic by removing redundant callback and establishing piping immediately after flushing buffered data
- Enhance debug logging during TLS ClientHello processing for improved SNI extraction insights
## 2025-03-12 - 3.41.7 - fix(core)
Refactor PortProxy and SniHandler: improve configuration handling, logging, and whitespace consistency
- Standardized indentation and spacing for configuration properties in PortProxy settings (e.g. ACME options, keepAliveProbes, allowSessionTicket)
- Simplified conditional formatting and improved inline comments in PortProxy
- Enhanced logging messages in SniHandler for TLS handshake and session resumption detection
- Improved debugging output (e.g. hexdump of initial TLS packet) and consistency of multi-line expressions
## 2025-03-12 - 3.41.6 - fix(SniHandler)
Refactor SniHandler: update whitespace, comment formatting, and consistent type definitions
- Unified inline comment style and spacing in SniHandler
- Refactored session cache type declaration for clarity
- Adjusted buffer length calculations to include TLS record header consistently
- Minor improvements to logging messages during ClientHello reassembly and SNI extraction
## 2025-03-12 - 3.41.5 - fix(portproxy)
Enforce TLS handshake and SNI validation on port 443 by blocking non-TLS connections and terminating session resumption attempts without SNI when allowSessionTicket is disabled.
- Added explicit check to block non-TLS connections on port 443 to ensure proper TLS usage.
- Enhanced logging for TLS ClientHello to include details on SNI extraction and session resumption status.
- Terminate connections with missing SNI by setting termination reasons ('session_ticket_blocked' or 'no_sni_blocked').
- Ensured consistent rejection of non-TLS handshakes on standard HTTPS port.
## 2025-03-12 - 3.41.4 - fix(tls/sni)
Improve logging for TLS session resumption by extracting and logging SNI values from ClientHello messages.
- Added logging to output the extracted SNI value during renegotiation, initial ClientHello and in the SNI handler.
- Enhanced error handling during SNI extraction to aid troubleshooting of TLS session resumption issues.
## 2025-03-12 - 3.41.3 - fix(TLS/SNI)
Improve TLS session resumption handling and logging. Now, session resumption attempts are always logged with details, and connections without a proper SNI are rejected when allowSessionTicket is disabled. In addition, empty SNI extensions are explicitly treated as missing, ensuring stricter and more consistent TLS handshake validation.
- Always log session resumption in both renegotiation and initial ClientHello processing.
- Terminate connections that attempt session resumption without SNI when allowSessionTicket is false.
- Treat empty SNI extensions as absence of SNI to improve consistency in TLS handshake processing.
## 2025-03-11 - 3.41.2 - fix(SniHandler)
Refactor hasSessionResumption to return detailed session resumption info
- Changed the return type of hasSessionResumption from boolean to an object with properties isResumption and hasSNI
- Updated early return conditions to return { isResumption: false, hasSNI: false } when buffer is too short or invalid
- Modified corresponding documentation to reflect the new return type
## 2025-03-11 - 3.41.1 - fix(SniHandler)
Improve TLS SNI session resumption handling: connections containing a session ticket are now only rejected when no SNI is present and allowSessionTicket is disabled. Updated return values and logging for clearer resumption detection.
- Changed SniHandler.hasSessionResumption to return an object with 'isResumption' and 'hasSNI' flags.
- Adjusted PortProxy logic to only terminate connections when a session ticket is detected without an accompanying SNI (when allowSessionTicket is false).
- Enhanced debug logging to clearly differentiate between session resumption scenarios with and without SNI.
## 2025-03-11 - 3.41.0 - feat(PortProxy/TLS)
Add allowSessionTicket option to control TLS session ticket handling
- Introduce 'allowSessionTicket' flag (default true) in PortProxy settings to enable or disable TLS session resumption via session tickets.
- Update SniHandler with a new hasSessionResumption method to detect session ticket and PSK extensions in ClientHello messages.
- Force connection cleanup during renegotiation and initial handshake when allowSessionTicket is set to false and a session ticket is detected.
## 2025-03-11 - 3.40.0 - feat(SniHandler)
Add session cache support and tab reactivation detection to improve SNI extraction in TLS handshakes

View File

@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartproxy",
"version": "3.40.0",
"version": "4.1.0",
"private": false,
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.",
"main": "dist_ts/index.js",

View File

@ -1,6 +1,6 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as net from 'net';
import { PortProxy } from '../ts/classes.portproxy.js';
import { PortProxy } from '../ts/classes.pp.portproxy.js';
let testServer: net.Server;
let portProxy: PortProxy;
@ -299,8 +299,8 @@ tap.test('should use round robin for multiple target IPs in domain config', asyn
// Don't track this proxy as it doesn't actually start or listen
const firstTarget = (proxyInstance as any).getTargetIP(domainConfig);
const secondTarget = (proxyInstance as any).getTargetIP(domainConfig);
const firstTarget = proxyInstance.domainConfigManager.getTargetIP(domainConfig);
const secondTarget = proxyInstance.domainConfigManager.getTargetIP(domainConfig);
expect(firstTarget).toEqual('hostA');
expect(secondTarget).toEqual('hostB');
});

View File

@ -226,8 +226,8 @@ tap.test('should start the proxy server', async () => {
// Awaiting the update ensures that the SNI context is added before any requests come in.
await testProxy.updateProxyConfigs([
{
destinationIp: '127.0.0.1',
destinationPort: '3000',
destinationIps: ['127.0.0.1'],
destinationPorts: [3000],
hostName: 'push.rocks',
publicKey: testCertificates.publicKey,
privateKey: testCertificates.privateKey,
@ -280,8 +280,8 @@ tap.test('should support WebSocket connections', async () => {
// Reconfigure proxy with test certificates if necessary
await testProxy.updateProxyConfigs([
{
destinationIp: '127.0.0.1',
destinationPort: '3000',
destinationIps: ['127.0.0.1'],
destinationPorts: [3000],
hostName: 'push.rocks',
publicKey: testCertificates.publicKey,
privateKey: testCertificates.privateKey,

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '3.40.0',
version: '4.1.0',
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.'
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,149 @@
import type { IPortProxySettings } from './classes.pp.interfaces.js';
import { NetworkProxyBridge } from './classes.pp.networkproxybridge.js';
/**
* Manages ACME certificate operations
*/
export class AcmeManager {
constructor(
private settings: IPortProxySettings,
private networkProxyBridge: NetworkProxyBridge
) {}
/**
* Get current ACME settings
*/
public getAcmeSettings(): IPortProxySettings['acme'] {
return this.settings.acme;
}
/**
* Check if ACME is enabled
*/
public isAcmeEnabled(): boolean {
return !!this.settings.acme?.enabled;
}
/**
* Update ACME certificate settings
*/
public async updateAcmeSettings(acmeSettings: IPortProxySettings['acme']): Promise<void> {
console.log('Updating ACME certificate settings');
// Check if enabled state is changing
const enabledChanging = this.settings.acme?.enabled !== acmeSettings.enabled;
// Update settings
this.settings.acme = {
...this.settings.acme,
...acmeSettings,
};
// Get NetworkProxy instance
const networkProxy = this.networkProxyBridge.getNetworkProxy();
if (!networkProxy) {
console.log('Cannot update ACME settings - NetworkProxy not initialized');
return;
}
try {
// If enabled state changed, we need to restart NetworkProxy
if (enabledChanging) {
console.log(`ACME enabled state changed to: ${acmeSettings.enabled}`);
// Stop the current NetworkProxy
await this.networkProxyBridge.stop();
// Reinitialize with new settings
await this.networkProxyBridge.initialize();
// Start NetworkProxy with new settings
await this.networkProxyBridge.start();
} else {
// Just update the settings in the existing NetworkProxy
console.log('Updating ACME settings in NetworkProxy without restart');
// Update settings in NetworkProxy
if (networkProxy.options && networkProxy.options.acme) {
networkProxy.options.acme = { ...this.settings.acme };
// For certificate renewals, we might want to trigger checks with the new settings
if (acmeSettings.renewThresholdDays !== undefined) {
console.log(`Setting new renewal threshold to ${acmeSettings.renewThresholdDays} days`);
networkProxy.options.acme.renewThresholdDays = acmeSettings.renewThresholdDays;
}
// Update other settings that might affect certificate operations
if (acmeSettings.useProduction !== undefined) {
console.log(`Setting ACME to ${acmeSettings.useProduction ? 'production' : 'staging'} mode`);
}
if (acmeSettings.autoRenew !== undefined) {
console.log(`Setting auto-renewal to ${acmeSettings.autoRenew ? 'enabled' : 'disabled'}`);
}
}
}
} catch (err) {
console.log(`Error updating ACME settings: ${err}`);
}
}
/**
* Request a certificate for a specific domain
*/
public async requestCertificate(domain: string): Promise<boolean> {
// Validate domain format
if (!this.isValidDomain(domain)) {
console.log(`Invalid domain format: ${domain}`);
return false;
}
// Delegate to NetworkProxyManager
return this.networkProxyBridge.requestCertificate(domain);
}
/**
* Basic domain validation
*/
private isValidDomain(domain: string): boolean {
// Very basic domain validation
if (!domain || domain.length === 0) {
return false;
}
// Check for wildcard domains (they can't get ACME certs)
if (domain.includes('*')) {
console.log(`Wildcard domains like "${domain}" are not supported for ACME certificates`);
return false;
}
// Check if domain has at least one dot and no invalid characters
const validDomainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
if (!validDomainRegex.test(domain)) {
console.log(`Domain "${domain}" has invalid format`);
return false;
}
return true;
}
/**
* Get eligible domains for ACME certificates
*/
public getEligibleDomains(): string[] {
// Collect all eligible domains from domain configs
const domains: string[] = [];
for (const config of this.settings.domainConfigs) {
// Skip domains that can't be used with ACME
const eligibleDomains = config.domains.filter(domain =>
!domain.includes('*') && this.isValidDomain(domain)
);
domains.push(...eligibleDomains);
}
return domains;
}
}

View File

@ -0,0 +1,982 @@
import * as plugins from './plugins.js';
import type { IConnectionRecord, IDomainConfig, IPortProxySettings } from './classes.pp.interfaces.js';
import { ConnectionManager } from './classes.pp.connectionmanager.js';
import { SecurityManager } from './classes.pp.securitymanager.js';
import { DomainConfigManager } from './classes.pp.domainconfigmanager.js';
import { TlsManager } from './classes.pp.tlsmanager.js';
import { NetworkProxyBridge } from './classes.pp.networkproxybridge.js';
import { TimeoutManager } from './classes.pp.timeoutmanager.js';
import { PortRangeManager } from './classes.pp.portrangemanager.js';
/**
* Handles new connection processing and setup logic
*/
export class ConnectionHandler {
constructor(
private settings: IPortProxySettings,
private connectionManager: ConnectionManager,
private securityManager: SecurityManager,
private domainConfigManager: DomainConfigManager,
private tlsManager: TlsManager,
private networkProxyBridge: NetworkProxyBridge,
private timeoutManager: TimeoutManager,
private portRangeManager: PortRangeManager
) {}
/**
* Handle a new incoming connection
*/
public handleConnection(socket: plugins.net.Socket): void {
const remoteIP = socket.remoteAddress || '';
const localPort = socket.localPort || 0;
// Validate IP against rate limits and connection limits
const ipValidation = this.securityManager.validateIP(remoteIP);
if (!ipValidation.allowed) {
console.log(`Connection rejected from ${remoteIP}: ${ipValidation.reason}`);
socket.end();
socket.destroy();
return;
}
// Create a new connection record
const record = this.connectionManager.createConnection(socket);
const connectionId = record.id;
// Apply socket optimizations
socket.setNoDelay(this.settings.noDelay);
// Apply keep-alive settings if enabled
if (this.settings.keepAlive) {
socket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
record.hasKeepAlive = true;
// Apply enhanced TCP keep-alive options if enabled
if (this.settings.enableKeepAliveProbes) {
try {
// These are platform-specific and may not be available
if ('setKeepAliveProbes' in socket) {
(socket as any).setKeepAliveProbes(10);
}
if ('setKeepAliveInterval' in socket) {
(socket as any).setKeepAliveInterval(1000);
}
} catch (err) {
// Ignore errors - these are optional enhancements
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Enhanced TCP keep-alive settings not supported: ${err}`);
}
}
}
}
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] New connection from ${remoteIP} on port ${localPort}. ` +
`Keep-Alive: ${record.hasKeepAlive ? 'Enabled' : 'Disabled'}. ` +
`Active connections: ${this.connectionManager.getConnectionCount()}`
);
} else {
console.log(
`New connection from ${remoteIP} on port ${localPort}. Active connections: ${this.connectionManager.getConnectionCount()}`
);
}
// Check if this connection should be forwarded directly to NetworkProxy
if (this.portRangeManager.shouldUseNetworkProxy(localPort)) {
this.handleNetworkProxyConnection(socket, record);
} else {
// For non-NetworkProxy ports, proceed with normal processing
this.handleStandardConnection(socket, record);
}
}
/**
* Handle a connection that should be forwarded to NetworkProxy
*/
private handleNetworkProxyConnection(socket: plugins.net.Socket, record: IConnectionRecord): void {
const connectionId = record.id;
let initialDataReceived = false;
// Set an initial timeout for handshake data
let initialTimeout: NodeJS.Timeout | null = setTimeout(() => {
if (!initialDataReceived) {
console.log(
`[${connectionId}] Initial data warning (${this.settings.initialDataTimeout}ms) for connection from ${record.remoteIP}`
);
// Add a grace period instead of immediate termination
setTimeout(() => {
if (!initialDataReceived) {
console.log(`[${connectionId}] Final initial data timeout after grace period`);
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'initial_timeout';
this.connectionManager.incrementTerminationStat('incoming', 'initial_timeout');
}
socket.end();
this.connectionManager.cleanupConnection(record, 'initial_timeout');
}
}, 30000); // 30 second grace period
}
}, this.settings.initialDataTimeout!);
// Make sure timeout doesn't keep the process alive
if (initialTimeout.unref) {
initialTimeout.unref();
}
// Set up error handler
socket.on('error', this.connectionManager.handleError('incoming', record));
// First data handler to capture initial TLS handshake for NetworkProxy
socket.once('data', (chunk: Buffer) => {
// Clear the initial timeout since we've received data
if (initialTimeout) {
clearTimeout(initialTimeout);
initialTimeout = null;
}
initialDataReceived = true;
record.hasReceivedInitialData = true;
// Block non-TLS connections on port 443
const localPort = record.localPort;
if (!this.tlsManager.isTlsHandshake(chunk) && localPort === 443) {
console.log(
`[${connectionId}] Non-TLS connection detected on port 443. ` +
`Terminating connection - only TLS traffic is allowed on standard HTTPS port.`
);
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'non_tls_blocked';
this.connectionManager.incrementTerminationStat('incoming', 'non_tls_blocked');
}
socket.end();
this.connectionManager.cleanupConnection(record, 'non_tls_blocked');
return;
}
// Check if this looks like a TLS handshake
if (this.tlsManager.isTlsHandshake(chunk)) {
record.isTLS = true;
// Check session tickets if they're disabled
if (this.settings.allowSessionTicket === false && this.tlsManager.isClientHello(chunk)) {
// Create connection info for SNI extraction
const connInfo = {
sourceIp: record.remoteIP,
sourcePort: socket.remotePort || 0,
destIp: socket.localAddress || '',
destPort: socket.localPort || 0,
};
// Extract SNI for domain-specific NetworkProxy handling
const serverName = this.tlsManager.extractSNI(chunk, connInfo);
if (serverName) {
// If we got an SNI, check for domain-specific NetworkProxy settings
const domainConfig = this.domainConfigManager.findDomainConfig(serverName);
// Save domain config and SNI in connection record
record.domainConfig = domainConfig;
record.lockedDomain = serverName;
// Use domain-specific NetworkProxy port if configured
if (domainConfig && this.domainConfigManager.shouldUseNetworkProxy(domainConfig)) {
const networkProxyPort = this.domainConfigManager.getNetworkProxyPort(domainConfig);
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Using domain-specific NetworkProxy for ${serverName} on port ${networkProxyPort}`
);
}
// Forward to NetworkProxy with domain-specific port
this.networkProxyBridge.forwardToNetworkProxy(
connectionId,
socket,
record,
chunk,
networkProxyPort,
(reason) => this.connectionManager.initiateCleanupOnce(record, reason)
);
return;
}
}
}
// Forward directly to NetworkProxy without domain-specific settings
this.networkProxyBridge.forwardToNetworkProxy(
connectionId,
socket,
record,
chunk,
undefined,
(reason) => this.connectionManager.initiateCleanupOnce(record, reason)
);
} else {
// If not TLS, use normal direct connection
console.log(`[${connectionId}] Non-TLS connection on NetworkProxy port ${record.localPort}`);
this.setupDirectConnection(
socket,
record,
undefined,
undefined,
chunk
);
}
});
}
/**
* Handle a standard (non-NetworkProxy) connection
*/
private handleStandardConnection(socket: plugins.net.Socket, record: IConnectionRecord): void {
const connectionId = record.id;
const localPort = record.localPort;
// Define helpers for rejecting connections
const rejectIncomingConnection = (reason: string, logMessage: string) => {
console.log(`[${connectionId}] ${logMessage}`);
socket.end();
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = reason;
this.connectionManager.incrementTerminationStat('incoming', reason);
}
this.connectionManager.cleanupConnection(record, reason);
};
let initialDataReceived = false;
// 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(
`[${connectionId}] Initial data warning (${this.settings.initialDataTimeout}ms) for connection from ${record.remoteIP}`
);
// Add a grace period instead of immediate termination
setTimeout(() => {
if (!initialDataReceived) {
console.log(`[${connectionId}] Final initial data timeout after grace period`);
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'initial_timeout';
this.connectionManager.incrementTerminationStat('incoming', 'initial_timeout');
}
socket.end();
this.connectionManager.cleanupConnection(record, 'initial_timeout');
}
}, 30000); // 30 second grace period
}
}, this.settings.initialDataTimeout!);
// Make sure timeout doesn't keep the process alive
if (initialTimeout.unref) {
initialTimeout.unref();
}
} else {
initialDataReceived = true;
record.hasReceivedInitialData = true;
}
socket.on('error', this.connectionManager.handleError('incoming', record));
// Track data for bytes counting
socket.on('data', (chunk: Buffer) => {
record.bytesReceived += chunk.length;
this.timeoutManager.updateActivity(record);
// Check for TLS handshake if this is the first chunk
if (!record.isTLS && this.tlsManager.isTlsHandshake(chunk)) {
record.isTLS = true;
if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] TLS handshake detected from ${record.remoteIP}, ${chunk.length} bytes`
);
}
}
});
/**
* Sets up the connection to the target host.
*/
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;
}
// Mark that we've received initial data
initialDataReceived = true;
record.hasReceivedInitialData = true;
// Check if this looks like a TLS handshake
if (initialChunk && this.tlsManager.isTlsHandshake(initialChunk)) {
record.isTLS = true;
if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] TLS handshake detected in setup, ${initialChunk.length} bytes`
);
}
}
// If a forcedDomain is provided (port-based routing), use it; otherwise, use SNI-based lookup.
const domainConfig = forcedDomain
? forcedDomain
: serverName
? this.domainConfigManager.findDomainConfig(serverName)
: undefined;
// Save domain config in connection record
record.domainConfig = domainConfig;
// Check if this domain should use NetworkProxy (domain-specific setting)
if (domainConfig &&
this.domainConfigManager.shouldUseNetworkProxy(domainConfig) &&
this.networkProxyBridge.getNetworkProxy()) {
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Domain ${serverName} is configured to use NetworkProxy`
);
}
const networkProxyPort = this.domainConfigManager.getNetworkProxyPort(domainConfig);
if (initialChunk && record.isTLS) {
// For TLS connections with initial chunk, forward to NetworkProxy
this.networkProxyBridge.forwardToNetworkProxy(
connectionId,
socket,
record,
initialChunk,
networkProxyPort,
(reason) => this.connectionManager.initiateCleanupOnce(record, reason)
);
return; // Skip normal connection setup
}
}
// IP validation
if (domainConfig) {
const ipRules = this.domainConfigManager.getEffectiveIPRules(domainConfig);
// Skip IP validation if allowedIPs is empty
if (
domainConfig.allowedIPs.length > 0 &&
!this.securityManager.isIPAuthorized(record.remoteIP, ipRules.allowedIPs, ipRules.blockedIPs)
) {
return rejectIncomingConnection(
'rejected',
`Connection rejected: IP ${record.remoteIP} not allowed for domain ${domainConfig.domains.join(
', '
)}`
);
}
} else if (
this.settings.defaultAllowedIPs &&
this.settings.defaultAllowedIPs.length > 0
) {
if (
!this.securityManager.isIPAuthorized(
record.remoteIP,
this.settings.defaultAllowedIPs,
this.settings.defaultBlockedIPs || []
)
) {
return rejectIncomingConnection(
'rejected',
`Connection rejected: IP ${record.remoteIP} not allowed by default allowed list`
);
}
}
// Save the initial SNI
if (serverName) {
record.lockedDomain = serverName;
}
// Set up the direct connection
this.setupDirectConnection(
socket,
record,
domainConfig,
serverName,
initialChunk,
overridePort
);
};
// --- PORT RANGE-BASED HANDLING ---
// Only apply port-based rules if the incoming port is within one of the global port ranges.
if (this.portRangeManager.isPortInGlobalRanges(localPort)) {
if (this.portRangeManager.shouldUseGlobalForwarding(localPort)) {
if (
this.settings.defaultAllowedIPs &&
this.settings.defaultAllowedIPs.length > 0 &&
!this.securityManager.isIPAuthorized(record.remoteIP, this.settings.defaultAllowedIPs)
) {
console.log(
`[${connectionId}] Connection from ${record.remoteIP} rejected: IP ${record.remoteIP} not allowed in global default allowed list.`
);
socket.end();
return;
}
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Port-based connection from ${record.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
);
return;
} else {
// Attempt to find a matching forced domain config based on the local port.
const forcedDomain = this.domainConfigManager.findDomainConfigForPort(localPort);
if (forcedDomain) {
const ipRules = this.domainConfigManager.getEffectiveIPRules(forcedDomain);
if (!this.securityManager.isIPAuthorized(record.remoteIP, ipRules.allowedIPs, ipRules.blockedIPs)) {
console.log(
`[${connectionId}] Connection from ${record.remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join(
', '
)} on port ${localPort}.`
);
socket.end();
return;
}
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Port-based connection from ${record.remoteIP} on port ${localPort} matched domain ${forcedDomain.domains.join(
', '
)}.`
);
}
setupConnection('', undefined, forcedDomain, localPort);
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) {
initialDataReceived = false;
socket.once('data', (chunk: Buffer) => {
// Clear timeout immediately
if (initialTimeout) {
clearTimeout(initialTimeout);
initialTimeout = null;
}
initialDataReceived = true;
// Block non-TLS connections on port 443
if (!this.tlsManager.isTlsHandshake(chunk) && localPort === 443) {
console.log(
`[${connectionId}] Non-TLS connection detected on port 443 in SNI handler. ` +
`Terminating connection - only TLS traffic is allowed on standard HTTPS port.`
);
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'non_tls_blocked';
this.connectionManager.incrementTerminationStat('incoming', 'non_tls_blocked');
}
socket.end();
this.connectionManager.cleanupConnection(record, 'non_tls_blocked');
return;
}
// Try to extract SNI
let serverName = '';
if (this.tlsManager.isTlsHandshake(chunk)) {
record.isTLS = true;
if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] Extracting SNI from TLS handshake, ${chunk.length} bytes`
);
}
// Create connection info object for SNI extraction
const connInfo = {
sourceIp: record.remoteIP,
sourcePort: socket.remotePort || 0,
destIp: socket.localAddress || '',
destPort: socket.localPort || 0,
};
// Extract SNI
serverName = this.tlsManager.extractSNI(chunk, connInfo) || '';
}
// Lock the connection to the negotiated SNI.
record.lockedDomain = serverName;
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Received connection from ${record.remoteIP} with SNI: ${
serverName || '(empty)'
}`
);
}
setupConnection(serverName, chunk);
});
} else {
initialDataReceived = true;
record.hasReceivedInitialData = true;
if (
this.settings.defaultAllowedIPs &&
this.settings.defaultAllowedIPs.length > 0 &&
!this.securityManager.isIPAuthorized(record.remoteIP, this.settings.defaultAllowedIPs)
) {
return rejectIncomingConnection(
'rejected',
`Connection rejected: IP ${record.remoteIP} not allowed for non-SNI connection`
);
}
setupConnection('');
}
}
/**
* Sets up a direct connection to the target
*/
private setupDirectConnection(
socket: plugins.net.Socket,
record: IConnectionRecord,
domainConfig?: IDomainConfig,
serverName?: string,
initialChunk?: Buffer,
overridePort?: number
): void {
const connectionId = record.id;
// Determine target host
const targetHost = domainConfig
? this.domainConfigManager.getTargetIP(domainConfig)
: this.settings.targetIP!;
// Determine target port
const targetPort = overridePort !== undefined
? overridePort
: this.settings.toPort;
// Setup connection options
const connectionOptions: plugins.net.NetConnectOpts = {
host: targetHost,
port: targetPort,
};
// Preserve source IP if configured
if (this.settings.preserveSourceIP) {
connectionOptions.localAddress = record.remoteIP.replace('::ffff:', '');
}
// Create a safe queue for incoming data
const dataQueue: Buffer[] = [];
let queueSize = 0;
let processingQueue = false;
let drainPending = false;
let pipingEstablished = false;
// Pause the incoming socket to prevent buffer overflows
socket.pause();
// Function to safely process the data queue without losing events
const processDataQueue = () => {
if (processingQueue || dataQueue.length === 0 || pipingEstablished) return;
processingQueue = true;
try {
// Process all queued chunks with the current active handler
while (dataQueue.length > 0) {
const chunk = dataQueue.shift()!;
queueSize -= chunk.length;
// Once piping is established, we shouldn't get here,
// but just in case, pass to the outgoing socket directly
if (pipingEstablished && record.outgoing) {
record.outgoing.write(chunk);
continue;
}
// Track bytes received
record.bytesReceived += chunk.length;
// Check for TLS handshake
if (!record.isTLS && this.tlsManager.isTlsHandshake(chunk)) {
record.isTLS = true;
if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] TLS handshake detected in tempDataHandler, ${chunk.length} bytes`
);
}
}
// Check if adding this chunk would exceed the buffer limit
const newSize = record.pendingDataSize + chunk.length;
if (this.settings.maxPendingDataSize && newSize > this.settings.maxPendingDataSize) {
console.log(
`[${connectionId}] Buffer limit exceeded for connection from ${record.remoteIP}: ${newSize} bytes > ${this.settings.maxPendingDataSize} bytes`
);
socket.end(); // Gracefully close the socket
this.connectionManager.initiateCleanupOnce(record, 'buffer_limit_exceeded');
return;
}
// Buffer the chunk and update the size counter
record.pendingData.push(Buffer.from(chunk));
record.pendingDataSize = newSize;
this.timeoutManager.updateActivity(record);
}
} finally {
processingQueue = false;
// If there's a pending drain and we've processed everything,
// signal we're ready for more data if we haven't established piping yet
if (drainPending && dataQueue.length === 0 && !pipingEstablished) {
drainPending = false;
socket.resume();
}
}
};
// Unified data handler that safely queues incoming data
const safeDataHandler = (chunk: Buffer) => {
// If piping is already established, just let the pipe handle it
if (pipingEstablished) return;
// Add to our queue for orderly processing
dataQueue.push(Buffer.from(chunk)); // Make a copy to be safe
queueSize += chunk.length;
// If queue is getting large, pause socket until we catch up
if (this.settings.maxPendingDataSize && queueSize > this.settings.maxPendingDataSize * 0.8) {
socket.pause();
drainPending = true;
}
// Process the queue
processDataQueue();
};
// Add our safe data handler
socket.on('data', safeDataHandler);
// Add initial chunk to pending data if present
if (initialChunk) {
record.bytesReceived += initialChunk.length;
record.pendingData.push(Buffer.from(initialChunk));
record.pendingDataSize = initialChunk.length;
}
// Create the target socket but don't set up piping immediately
const targetSocket = plugins.net.connect(connectionOptions);
record.outgoing = targetSocket;
record.outgoingStartTime = Date.now();
// Apply socket optimizations
targetSocket.setNoDelay(this.settings.noDelay);
// Apply keep-alive settings to the outgoing connection as well
if (this.settings.keepAlive) {
targetSocket.setKeepAlive(true, this.settings.keepAliveInitialDelay);
// Apply enhanced TCP keep-alive options if enabled
if (this.settings.enableKeepAliveProbes) {
try {
if ('setKeepAliveProbes' in targetSocket) {
(targetSocket as any).setKeepAliveProbes(10);
}
if ('setKeepAliveInterval' in targetSocket) {
(targetSocket as any).setKeepAliveInterval(1000);
}
} catch (err) {
// Ignore errors - these are optional enhancements
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Enhanced TCP keep-alive not supported for outgoing socket: ${err}`
);
}
}
}
}
// Setup specific error handler for connection phase
targetSocket.once('error', (err) => {
// This handler runs only once during the initial connection phase
const code = (err as any).code;
console.log(
`[${connectionId}] Connection setup error to ${targetHost}:${connectionOptions.port}: ${err.message} (${code})`
);
// Resume the incoming socket to prevent it from hanging
socket.resume();
if (code === 'ECONNREFUSED') {
console.log(
`[${connectionId}] Target ${targetHost}:${connectionOptions.port} refused connection`
);
} else if (code === 'ETIMEDOUT') {
console.log(
`[${connectionId}] Connection to ${targetHost}:${connectionOptions.port} timed out`
);
} else if (code === 'ECONNRESET') {
console.log(
`[${connectionId}] Connection to ${targetHost}:${connectionOptions.port} was reset`
);
} else if (code === 'EHOSTUNREACH') {
console.log(`[${connectionId}] Host ${targetHost} is unreachable`);
}
// Clear any existing error handler after connection phase
targetSocket.removeAllListeners('error');
// Re-add the normal error handler for established connections
targetSocket.on('error', this.connectionManager.handleError('outgoing', record));
if (record.outgoingTerminationReason === null) {
record.outgoingTerminationReason = 'connection_failed';
this.connectionManager.incrementTerminationStat('outgoing', 'connection_failed');
}
// Clean up the connection
this.connectionManager.initiateCleanupOnce(record, `connection_failed_${code}`);
});
// Setup close handler
targetSocket.on('close', this.connectionManager.handleClose('outgoing', record));
socket.on('close', this.connectionManager.handleClose('incoming', record));
// Handle timeouts with keep-alive awareness
socket.on('timeout', () => {
// For keep-alive connections, just log a warning instead of closing
if (record.hasKeepAlive) {
console.log(
`[${connectionId}] Timeout event on incoming keep-alive connection from ${
record.remoteIP
} after ${plugins.prettyMs(
this.settings.socketTimeout || 3600000
)}. Connection preserved.`
);
return;
}
// For non-keep-alive connections, proceed with normal cleanup
console.log(
`[${connectionId}] Timeout on incoming side from ${
record.remoteIP
} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`
);
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'timeout';
this.connectionManager.incrementTerminationStat('incoming', 'timeout');
}
this.connectionManager.initiateCleanupOnce(record, 'timeout_incoming');
});
targetSocket.on('timeout', () => {
// For keep-alive connections, just log a warning instead of closing
if (record.hasKeepAlive) {
console.log(
`[${connectionId}] Timeout event on outgoing keep-alive connection from ${
record.remoteIP
} after ${plugins.prettyMs(
this.settings.socketTimeout || 3600000
)}. Connection preserved.`
);
return;
}
// For non-keep-alive connections, proceed with normal cleanup
console.log(
`[${connectionId}] Timeout on outgoing side from ${
record.remoteIP
} after ${plugins.prettyMs(this.settings.socketTimeout || 3600000)}`
);
if (record.outgoingTerminationReason === null) {
record.outgoingTerminationReason = 'timeout';
this.connectionManager.incrementTerminationStat('outgoing', 'timeout');
}
this.connectionManager.initiateCleanupOnce(record, 'timeout_outgoing');
});
// Apply socket timeouts
this.timeoutManager.applySocketTimeouts(record);
// Track outgoing data for bytes counting
targetSocket.on('data', (chunk: Buffer) => {
record.bytesSent += chunk.length;
this.timeoutManager.updateActivity(record);
});
// Wait for the outgoing connection to be ready before setting up piping
targetSocket.once('connect', () => {
// Clear the initial connection error handler
targetSocket.removeAllListeners('error');
// Add the normal error handler for established connections
targetSocket.on('error', this.connectionManager.handleError('outgoing', record));
// Process any remaining data in the queue before switching to piping
processDataQueue();
// Set up piping immediately
pipingEstablished = true;
// Flush all pending data to target
if (record.pendingData.length > 0) {
const combinedData = Buffer.concat(record.pendingData);
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`);
}
// Write pending data immediately
targetSocket.write(combinedData, (err) => {
if (err) {
console.log(`[${connectionId}] Error writing pending data to target: ${err.message}`);
return this.connectionManager.initiateCleanupOnce(record, 'write_error');
}
});
// Clear the buffer now that we've processed it
record.pendingData = [];
record.pendingDataSize = 0;
}
// Setup piping in both directions without any delays
socket.pipe(targetSocket);
targetSocket.pipe(socket);
// Resume the socket to ensure data flows
socket.resume();
// Process any data that might be queued in the interim
if (dataQueue.length > 0) {
// Write any remaining queued data directly to the target socket
for (const chunk of dataQueue) {
targetSocket.write(chunk);
}
// Clear the queue
dataQueue.length = 0;
queueSize = 0;
}
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${
serverName
? ` (SNI: ${serverName})`
: domainConfig
? ` (Port-based for domain: ${domainConfig.domains.join(', ')})`
: ''
}` +
` TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${
record.hasKeepAlive ? 'Yes' : 'No'
}`
);
} else {
console.log(
`Connection established: ${record.remoteIP} -> ${targetHost}:${connectionOptions.port}` +
`${
serverName
? ` (SNI: ${serverName})`
: domainConfig
? ` (Port-based for domain: ${domainConfig.domains.join(', ')})`
: ''
}`
);
}
// Add the renegotiation handler for SNI validation
if (serverName) {
// Create connection info object for the existing connection
const connInfo = {
sourceIp: record.remoteIP,
sourcePort: record.incoming.remotePort || 0,
destIp: record.incoming.localAddress || '',
destPort: record.incoming.localPort || 0,
};
// Create a renegotiation handler function
const renegotiationHandler = this.tlsManager.createRenegotiationHandler(
connectionId,
serverName,
connInfo,
(connectionId, reason) => this.connectionManager.initiateCleanupOnce(record, reason)
);
// Store the handler in the connection record so we can remove it during cleanup
record.renegotiationHandler = renegotiationHandler;
// Add the handler to the socket
socket.on('data', renegotiationHandler);
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] TLS renegotiation handler installed for SNI domain: ${serverName}`
);
if (this.settings.allowSessionTicket === false) {
console.log(
`[${connectionId}] Session ticket usage is disabled. Connection will be reset on reconnection attempts.`
);
}
}
}
// Set connection timeout
record.cleanupTimer = this.timeoutManager.setupConnectionTimeout(
record,
(record, reason) => {
console.log(
`[${connectionId}] Connection from ${record.remoteIP} exceeded max lifetime, forcing cleanup.`
);
this.connectionManager.initiateCleanupOnce(record, reason);
}
);
// Mark TLS handshake as complete for TLS connections
if (record.isTLS) {
record.tlsHandshakeComplete = true;
if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] TLS handshake complete for connection from ${record.remoteIP}`
);
}
}
});
}
}

View File

@ -0,0 +1,446 @@
import * as plugins from './plugins.js';
import type { IConnectionRecord, IPortProxySettings } from './classes.pp.interfaces.js';
import { SecurityManager } from './classes.pp.securitymanager.js';
import { TimeoutManager } from './classes.pp.timeoutmanager.js';
/**
* Manages connection lifecycle, tracking, and cleanup
*/
export class ConnectionManager {
private connectionRecords: Map<string, IConnectionRecord> = new Map();
private terminationStats: {
incoming: Record<string, number>;
outgoing: Record<string, number>;
} = { incoming: {}, outgoing: {} };
constructor(
private settings: IPortProxySettings,
private securityManager: SecurityManager,
private timeoutManager: TimeoutManager
) {}
/**
* Generate a unique connection ID
*/
public generateConnectionId(): string {
return Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
}
/**
* Create and track a new connection
*/
public createConnection(socket: plugins.net.Socket): IConnectionRecord {
const connectionId = this.generateConnectionId();
const remoteIP = socket.remoteAddress || '';
const localPort = socket.localPort || 0;
const record: IConnectionRecord = {
id: connectionId,
incoming: socket,
outgoing: null,
incomingStartTime: Date.now(),
lastActivity: Date.now(),
connectionClosed: false,
pendingData: [],
pendingDataSize: 0,
bytesReceived: 0,
bytesSent: 0,
remoteIP,
localPort,
isTLS: false,
tlsHandshakeComplete: false,
hasReceivedInitialData: false,
hasKeepAlive: false,
incomingTerminationReason: null,
outgoingTerminationReason: null,
usingNetworkProxy: false,
isBrowserConnection: false,
domainSwitches: 0
};
this.trackConnection(connectionId, record);
return record;
}
/**
* Track an existing connection
*/
public trackConnection(connectionId: string, record: IConnectionRecord): void {
this.connectionRecords.set(connectionId, record);
this.securityManager.trackConnectionByIP(record.remoteIP, connectionId);
}
/**
* Get a connection by ID
*/
public getConnection(connectionId: string): IConnectionRecord | undefined {
return this.connectionRecords.get(connectionId);
}
/**
* Get all active connections
*/
public getConnections(): Map<string, IConnectionRecord> {
return this.connectionRecords;
}
/**
* Get count of active connections
*/
public getConnectionCount(): number {
return this.connectionRecords.size;
}
/**
* Initiates cleanup once for a connection
*/
public initiateCleanupOnce(record: IConnectionRecord, reason: string = 'normal'): void {
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] Connection cleanup initiated for ${record.remoteIP} (${reason})`);
}
if (
record.incomingTerminationReason === null ||
record.incomingTerminationReason === undefined
) {
record.incomingTerminationReason = reason;
this.incrementTerminationStat('incoming', reason);
}
this.cleanupConnection(record, reason);
}
/**
* Clean up a connection record
*/
public cleanupConnection(record: IConnectionRecord, reason: string = 'normal'): void {
if (!record.connectionClosed) {
record.connectionClosed = true;
// Track connection termination
this.securityManager.removeConnectionByIP(record.remoteIP, record.id);
if (record.cleanupTimer) {
clearTimeout(record.cleanupTimer);
record.cleanupTimer = undefined;
}
// Detailed logging data
const duration = Date.now() - record.incomingStartTime;
const bytesReceived = record.bytesReceived;
const bytesSent = record.bytesSent;
// Remove all data handlers to make sure we clean up properly
if (record.incoming) {
try {
// Remove our safe data handler
record.incoming.removeAllListeners('data');
// Reset the handler references
record.renegotiationHandler = undefined;
} catch (err) {
console.log(`[${record.id}] Error removing data handlers: ${err}`);
}
}
// Handle incoming socket
this.cleanupSocket(record, 'incoming', record.incoming);
// Handle outgoing socket
if (record.outgoing) {
this.cleanupSocket(record, 'outgoing', record.outgoing);
}
// Clear pendingData to avoid memory leaks
record.pendingData = [];
record.pendingDataSize = 0;
// Remove the record from the tracking map
this.connectionRecords.delete(record.id);
// Log connection details
if (this.settings.enableDetailedLogging) {
console.log(
`[${record.id}] Connection from ${record.remoteIP} on port ${record.localPort} terminated (${reason}).` +
` Duration: ${plugins.prettyMs(duration)}, Bytes IN: ${bytesReceived}, OUT: ${bytesSent}, ` +
`TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${record.hasKeepAlive ? 'Yes' : 'No'}` +
`${record.usingNetworkProxy ? ', Using NetworkProxy' : ''}` +
`${record.domainSwitches ? `, Domain switches: ${record.domainSwitches}` : ''}`
);
} else {
console.log(
`[${record.id}] Connection from ${record.remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}`
);
}
}
}
/**
* Helper method to clean up a socket
*/
private cleanupSocket(record: IConnectionRecord, side: 'incoming' | 'outgoing', socket: plugins.net.Socket): void {
try {
if (!socket.destroyed) {
// Try graceful shutdown first, then force destroy after a short timeout
socket.end();
const socketTimeout = setTimeout(() => {
try {
if (!socket.destroyed) {
socket.destroy();
}
} catch (err) {
console.log(`[${record.id}] Error destroying ${side} socket: ${err}`);
}
}, 1000);
// Ensure the timeout doesn't block Node from exiting
if (socketTimeout.unref) {
socketTimeout.unref();
}
}
} catch (err) {
console.log(`[${record.id}] Error closing ${side} socket: ${err}`);
try {
if (!socket.destroyed) {
socket.destroy();
}
} catch (destroyErr) {
console.log(`[${record.id}] Error destroying ${side} socket: ${destroyErr}`);
}
}
}
/**
* Creates a generic error handler for incoming or outgoing sockets
*/
public handleError(side: 'incoming' | 'outgoing', record: IConnectionRecord) {
return (err: Error) => {
const code = (err as any).code;
let reason = 'error';
const now = Date.now();
const connectionDuration = now - record.incomingStartTime;
const lastActivityAge = now - record.lastActivity;
if (code === 'ECONNRESET') {
reason = 'econnreset';
console.log(
`[${record.id}] ECONNRESET on ${side} side from ${record.remoteIP}: ${err.message}. ` +
`Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago`
);
} else if (code === 'ETIMEDOUT') {
reason = 'etimedout';
console.log(
`[${record.id}] ETIMEDOUT on ${side} side from ${record.remoteIP}: ${err.message}. ` +
`Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago`
);
} else {
console.log(
`[${record.id}] Error on ${side} side from ${record.remoteIP}: ${err.message}. ` +
`Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago`
);
}
if (side === 'incoming' && record.incomingTerminationReason === null) {
record.incomingTerminationReason = reason;
this.incrementTerminationStat('incoming', reason);
} else if (side === 'outgoing' && record.outgoingTerminationReason === null) {
record.outgoingTerminationReason = reason;
this.incrementTerminationStat('outgoing', reason);
}
this.initiateCleanupOnce(record, reason);
};
}
/**
* Creates a generic close handler for incoming or outgoing sockets
*/
public handleClose(side: 'incoming' | 'outgoing', record: IConnectionRecord) {
return () => {
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] Connection closed on ${side} side from ${record.remoteIP}`);
}
if (side === 'incoming' && record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'normal';
this.incrementTerminationStat('incoming', 'normal');
} else if (side === 'outgoing' && record.outgoingTerminationReason === null) {
record.outgoingTerminationReason = 'normal';
this.incrementTerminationStat('outgoing', 'normal');
// Record the time when outgoing socket closed.
record.outgoingClosedTime = Date.now();
}
this.initiateCleanupOnce(record, 'closed_' + side);
};
}
/**
* Increment termination statistics
*/
public incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void {
this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1;
}
/**
* Get termination statistics
*/
public getTerminationStats(): { incoming: Record<string, number>; outgoing: Record<string, number> } {
return this.terminationStats;
}
/**
* Check for stalled/inactive connections
*/
public performInactivityCheck(): void {
const now = Date.now();
const connectionIds = [...this.connectionRecords.keys()];
for (const id of connectionIds) {
const record = this.connectionRecords.get(id);
if (!record) continue;
// Skip inactivity check if disabled or for immortal keep-alive connections
if (
this.settings.disableInactivityCheck ||
(record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal')
) {
continue;
}
const inactivityTime = now - record.lastActivity;
// Use extended timeout for extended-treatment keep-alive connections
let effectiveTimeout = this.settings.inactivityTimeout!;
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
const multiplier = this.settings.keepAliveInactivityMultiplier || 6;
effectiveTimeout = effectiveTimeout * multiplier;
}
if (inactivityTime > effectiveTimeout && !record.connectionClosed) {
// For keep-alive connections, issue a warning first
if (record.hasKeepAlive && !record.inactivityWarningIssued) {
console.log(
`[${id}] Warning: Keep-alive connection from ${record.remoteIP} inactive for ${
plugins.prettyMs(inactivityTime)
}. Will close in 10 minutes if no activity.`
);
// Set warning flag and add grace period
record.inactivityWarningIssued = true;
record.lastActivity = now - (effectiveTimeout - 600000);
// Try to stimulate activity with a probe packet
if (record.outgoing && !record.outgoing.destroyed) {
try {
record.outgoing.write(Buffer.alloc(0));
if (this.settings.enableDetailedLogging) {
console.log(`[${id}] Sent probe packet to test keep-alive connection`);
}
} catch (err) {
console.log(`[${id}] Error sending probe packet: ${err}`);
}
}
} else {
// For non-keep-alive or after warning, close the connection
console.log(
`[${id}] Inactivity check: No activity on connection from ${record.remoteIP} ` +
`for ${plugins.prettyMs(inactivityTime)}.` +
(record.hasKeepAlive ? ' Despite keep-alive being enabled.' : '')
);
this.cleanupConnection(record, 'inactivity');
}
} else if (inactivityTime <= effectiveTimeout && record.inactivityWarningIssued) {
// If activity detected after warning, clear the warning
if (this.settings.enableDetailedLogging) {
console.log(
`[${id}] Connection activity detected after inactivity warning, resetting warning`
);
}
record.inactivityWarningIssued = false;
}
// Parity check: if outgoing socket closed and incoming remains active
if (
record.outgoingClosedTime &&
!record.incoming.destroyed &&
!record.connectionClosed &&
now - record.outgoingClosedTime > 120000
) {
console.log(
`[${id}] Parity check: Incoming socket for ${record.remoteIP} still active ${
plugins.prettyMs(now - record.outgoingClosedTime)
} after outgoing closed.`
);
this.cleanupConnection(record, 'parity_check');
}
}
}
/**
* Clear all connections (for shutdown)
*/
public clearConnections(): void {
// Create a copy of the keys to avoid modification during iteration
const connectionIds = [...this.connectionRecords.keys()];
// First pass: End all connections gracefully
for (const id of connectionIds) {
const record = this.connectionRecords.get(id);
if (record) {
try {
// Clear any timers
if (record.cleanupTimer) {
clearTimeout(record.cleanupTimer);
record.cleanupTimer = undefined;
}
// End sockets gracefully
if (record.incoming && !record.incoming.destroyed) {
record.incoming.end();
}
if (record.outgoing && !record.outgoing.destroyed) {
record.outgoing.end();
}
} catch (err) {
console.log(`Error during graceful connection end for ${id}: ${err}`);
}
}
}
// Short delay to allow graceful ends to process
setTimeout(() => {
// Second pass: Force destroy everything
for (const id of connectionIds) {
const record = this.connectionRecords.get(id);
if (record) {
try {
// Remove all listeners to prevent memory leaks
if (record.incoming) {
record.incoming.removeAllListeners();
if (!record.incoming.destroyed) {
record.incoming.destroy();
}
}
if (record.outgoing) {
record.outgoing.removeAllListeners();
if (!record.outgoing.destroyed) {
record.outgoing.destroy();
}
}
} catch (err) {
console.log(`Error during forced connection destruction for ${id}: ${err}`);
}
}
}
// Clear all maps
this.connectionRecords.clear();
this.terminationStats = { incoming: {}, outgoing: {} };
}, 100);
}
}

View File

@ -0,0 +1,123 @@
import * as plugins from './plugins.js';
import type { IDomainConfig, IPortProxySettings } from './classes.pp.interfaces.js';
/**
* Manages domain configurations and target selection
*/
export class DomainConfigManager {
// Track round-robin indices for domain configs
private domainTargetIndices: Map<IDomainConfig, number> = new Map();
constructor(private settings: IPortProxySettings) {}
/**
* Updates the domain configurations
*/
public updateDomainConfigs(newDomainConfigs: IDomainConfig[]): void {
this.settings.domainConfigs = newDomainConfigs;
// Reset target indices for removed configs
const currentConfigSet = new Set(newDomainConfigs);
for (const [config] of this.domainTargetIndices) {
if (!currentConfigSet.has(config)) {
this.domainTargetIndices.delete(config);
}
}
}
/**
* Get all domain configurations
*/
public getDomainConfigs(): IDomainConfig[] {
return this.settings.domainConfigs;
}
/**
* Find domain config matching a server name
*/
public findDomainConfig(serverName: string): IDomainConfig | undefined {
if (!serverName) return undefined;
return this.settings.domainConfigs.find((config) =>
config.domains.some((d) => plugins.minimatch(serverName, d))
);
}
/**
* Find domain config for a specific port
*/
public findDomainConfigForPort(port: number): IDomainConfig | undefined {
return this.settings.domainConfigs.find(
(domain) =>
domain.portRanges &&
domain.portRanges.length > 0 &&
this.isPortInRanges(port, domain.portRanges)
);
}
/**
* Check if a port is within any of the given ranges
*/
public isPortInRanges(port: number, ranges: Array<{ from: number; to: number }>): boolean {
return ranges.some((range) => port >= range.from && port <= range.to);
}
/**
* Get target IP with round-robin support
*/
public getTargetIP(domainConfig: IDomainConfig): string {
if (domainConfig.targetIPs && domainConfig.targetIPs.length > 0) {
const currentIndex = this.domainTargetIndices.get(domainConfig) || 0;
const ip = domainConfig.targetIPs[currentIndex % domainConfig.targetIPs.length];
this.domainTargetIndices.set(domainConfig, currentIndex + 1);
return ip;
}
return this.settings.targetIP || 'localhost';
}
/**
* Checks if a domain should use NetworkProxy
*/
public shouldUseNetworkProxy(domainConfig: IDomainConfig): boolean {
return !!domainConfig.useNetworkProxy;
}
/**
* Gets the NetworkProxy port for a domain
*/
public getNetworkProxyPort(domainConfig: IDomainConfig): number | undefined {
return domainConfig.useNetworkProxy
? (domainConfig.networkProxyPort || this.settings.networkProxyPort)
: undefined;
}
/**
* Get effective allowed and blocked IPs for a domain
*/
public getEffectiveIPRules(domainConfig: IDomainConfig): {
allowedIPs: string[],
blockedIPs: string[]
} {
return {
allowedIPs: [
...domainConfig.allowedIPs,
...(this.settings.defaultAllowedIPs || [])
],
blockedIPs: [
...(domainConfig.blockedIPs || []),
...(this.settings.defaultBlockedIPs || [])
]
};
}
/**
* Get connection timeout for a domain
*/
public getConnectionTimeout(domainConfig?: IDomainConfig): number {
if (domainConfig?.connectionTimeout) {
return domainConfig.connectionTimeout;
}
return this.settings.maxConnectionLifetime || 86400000; // 24 hours default
}
}

136
ts/classes.pp.interfaces.ts Normal file
View File

@ -0,0 +1,136 @@
import * as plugins from './plugins.js';
/** 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
// Allow domain-specific timeout override
connectionTimeout?: number; // Connection timeout override (ms)
// NetworkProxy integration options for this specific domain
useNetworkProxy?: boolean; // Whether to use NetworkProxy for this domain
networkProxyPort?: number; // Override default NetworkProxy port for this domain
}
/** Port proxy settings including global allowed port ranges */
export interface IPortProxySettings {
fromPort: number;
toPort: number;
targetIP?: string; // Global target host to proxy to, defaults to 'localhost'
domainConfigs: IDomainConfig[];
sniEnabled?: boolean;
defaultAllowedIPs?: string[];
defaultBlockedIPs?: string[];
preserveSourceIP?: boolean;
// TLS options
pfx?: Buffer;
key?: string | Buffer | Array<Buffer | string>;
passphrase?: string;
cert?: string | Buffer | Array<string | Buffer>;
ca?: string | Buffer | Array<string | Buffer>;
ciphers?: string;
honorCipherOrder?: boolean;
rejectUnauthorized?: boolean;
secureProtocol?: string;
servername?: string;
minVersion?: string;
maxVersion?: string;
// Timeout settings
initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 60000 (60s)
socketTimeout?: number; // Socket inactivity timeout (ms), default: 3600000 (1h)
inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 60000 (60s)
maxConnectionLifetime?: number; // Default max connection lifetime (ms), default: 86400000 (24h)
inactivityTimeout?: number; // Inactivity timeout (ms), default: 14400000 (4h)
gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
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
// Socket optimization settings
noDelay?: boolean; // Disable Nagle's algorithm (default: true)
keepAlive?: boolean; // Enable TCP keepalive (default: true)
keepAliveInitialDelay?: number; // Initial delay before sending keepalive probes (ms)
maxPendingDataSize?: number; // Maximum bytes to buffer during connection setup
// Enhanced features
disableInactivityCheck?: boolean; // Disable inactivity checking entirely
enableKeepAliveProbes?: boolean; // Enable TCP keep-alive probes
enableDetailedLogging?: boolean; // Enable detailed connection logging
enableTlsDebugLogging?: boolean; // Enable TLS handshake debug logging
enableRandomizedTimeouts?: boolean; // Randomize timeouts slightly to prevent thundering herd
allowSessionTicket?: boolean; // Allow TLS session ticket for reconnection (default: true)
// Rate limiting and security
maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP
connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP
// Enhanced keep-alive settings
keepAliveTreatment?: 'standard' | 'extended' | 'immortal'; // How to treat keep-alive connections
keepAliveInactivityMultiplier?: number; // Multiplier for inactivity timeout for keep-alive connections
extendedKeepAliveLifetime?: number; // Extended lifetime for keep-alive connections (ms)
// NetworkProxy integration
useNetworkProxy?: number[]; // Array of ports to forward to NetworkProxy
networkProxyPort?: number; // Port where NetworkProxy is listening (default: 8443)
// ACME certificate management options
acme?: {
enabled?: boolean; // Whether to enable automatic certificate management
port?: number; // Port to listen on for ACME challenges (default: 80)
contactEmail?: string; // Email for Let's Encrypt account
useProduction?: boolean; // Whether to use Let's Encrypt production (default: false for staging)
renewThresholdDays?: number; // Days before expiry to renew certificates (default: 30)
autoRenew?: boolean; // Whether to automatically renew certificates (default: true)
certificateStore?: string; // Directory to store certificates (default: ./certs)
skipConfiguredCerts?: boolean; // Skip domains that already have certificates configured
};
}
/**
* Enhanced connection record
*/
export interface IConnectionRecord {
id: string; // Unique connection identifier
incoming: plugins.net.Socket;
outgoing: plugins.net.Socket | null;
incomingStartTime: number;
outgoingStartTime?: number;
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
pendingData: Buffer[]; // Buffer to hold data during connection setup
pendingDataSize: number; // Track total size of pending data
// Enhanced tracking fields
bytesReceived: number; // Total bytes received
bytesSent: number; // Total bytes sent
remoteIP: string; // Remote IP (cached for logging after socket close)
localPort: number; // Local port (cached for logging)
isTLS: boolean; // Whether this connection is a TLS connection
tlsHandshakeComplete: boolean; // Whether the TLS handshake is complete
hasReceivedInitialData: boolean; // Whether initial data has been received
domainConfig?: IDomainConfig; // Associated domain config for this connection
// Keep-alive tracking
hasKeepAlive: boolean; // Whether keep-alive is enabled for this connection
inactivityWarningIssued?: boolean; // Whether an inactivity warning has been issued
incomingTerminationReason?: string | null; // Reason for incoming termination
outgoingTerminationReason?: string | null; // Reason for outgoing termination
// NetworkProxy tracking
usingNetworkProxy?: boolean; // Whether this connection is using a NetworkProxy
// Renegotiation handler
renegotiationHandler?: (chunk: Buffer) => void; // Handler for renegotiation detection
// Browser connection tracking
isBrowserConnection?: boolean; // Whether this connection appears to be from a browser
domainSwitches?: number; // Number of times the domain has been switched on this connection
}

View File

@ -0,0 +1,258 @@
import * as plugins from './plugins.js';
import { NetworkProxy } from './classes.networkproxy.js';
import type { IConnectionRecord, IPortProxySettings, IDomainConfig } from './classes.pp.interfaces.js';
/**
* Manages NetworkProxy integration for TLS termination
*/
export class NetworkProxyBridge {
private networkProxy: NetworkProxy | null = null;
constructor(private settings: IPortProxySettings) {}
/**
* Initialize NetworkProxy instance
*/
public async initialize(): Promise<void> {
if (!this.networkProxy && this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0) {
// Configure NetworkProxy options based on PortProxy settings
const networkProxyOptions: any = {
port: this.settings.networkProxyPort!,
portProxyIntegration: true,
logLevel: this.settings.enableDetailedLogging ? 'debug' : 'info',
};
// Add ACME settings if configured
if (this.settings.acme) {
networkProxyOptions.acme = { ...this.settings.acme };
}
this.networkProxy = new NetworkProxy(networkProxyOptions);
console.log(`Initialized NetworkProxy on port ${this.settings.networkProxyPort}`);
// Convert and apply domain configurations to NetworkProxy
await this.syncDomainConfigsToNetworkProxy();
}
}
/**
* Get the NetworkProxy instance
*/
public getNetworkProxy(): NetworkProxy | null {
return this.networkProxy;
}
/**
* Get the NetworkProxy port
*/
public getNetworkProxyPort(): number {
return this.networkProxy ? this.networkProxy.getListeningPort() : this.settings.networkProxyPort || 8443;
}
/**
* Start NetworkProxy
*/
public async start(): Promise<void> {
if (this.networkProxy) {
await this.networkProxy.start();
console.log(`NetworkProxy started on port ${this.settings.networkProxyPort}`);
// Log ACME status
if (this.settings.acme?.enabled) {
console.log(
`ACME certificate management is enabled (${
this.settings.acme.useProduction ? 'Production' : 'Staging'
} mode)`
);
console.log(`ACME HTTP challenge server on port ${this.settings.acme.port}`);
// Register domains for ACME certificates if enabled
if (this.networkProxy.options.acme?.enabled) {
console.log('Registering domains with ACME certificate manager...');
// The NetworkProxy will handle this internally via registerDomainsWithAcmeManager()
}
}
}
}
/**
* Stop NetworkProxy
*/
public async stop(): Promise<void> {
if (this.networkProxy) {
try {
console.log('Stopping NetworkProxy...');
await this.networkProxy.stop();
console.log('NetworkProxy stopped successfully');
// Log ACME shutdown if it was enabled
if (this.settings.acme?.enabled) {
console.log('ACME certificate manager stopped');
}
} catch (err) {
console.log(`Error stopping NetworkProxy: ${err}`);
}
}
}
/**
* Forwards a TLS connection to a NetworkProxy for handling
*/
public forwardToNetworkProxy(
connectionId: string,
socket: plugins.net.Socket,
record: IConnectionRecord,
initialData: Buffer,
customProxyPort?: number,
onError?: (reason: string) => void
): void {
// Ensure NetworkProxy is initialized
if (!this.networkProxy) {
console.log(
`[${connectionId}] NetworkProxy not initialized. Cannot forward connection.`
);
if (onError) {
onError('network_proxy_not_initialized');
}
return;
}
// Use the custom port if provided, otherwise use the default NetworkProxy port
const proxyPort = customProxyPort || this.networkProxy.getListeningPort();
const proxyHost = 'localhost'; // Assuming NetworkProxy runs locally
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Forwarding TLS connection to NetworkProxy at ${proxyHost}:${proxyPort}`
);
}
// Create a connection to the NetworkProxy
const proxySocket = plugins.net.connect({
host: proxyHost,
port: proxyPort,
});
// Store the outgoing socket in the record
record.outgoing = proxySocket;
record.outgoingStartTime = Date.now();
record.usingNetworkProxy = true;
// Set up error handlers
proxySocket.on('error', (err) => {
console.log(`[${connectionId}] Error connecting to NetworkProxy: ${err.message}`);
if (onError) {
onError('network_proxy_connect_error');
}
});
// Handle connection to NetworkProxy
proxySocket.on('connect', () => {
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Connected to NetworkProxy at ${proxyHost}:${proxyPort}`);
}
// First send the initial data that contains the TLS ClientHello
proxySocket.write(initialData);
// Now set up bidirectional piping between client and NetworkProxy
socket.pipe(proxySocket);
proxySocket.pipe(socket);
// Update activity on data transfer (caller should handle this)
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] TLS connection successfully forwarded to NetworkProxy`);
}
});
}
/**
* Synchronizes domain configurations to NetworkProxy
*/
public async syncDomainConfigsToNetworkProxy(): Promise<void> {
if (!this.networkProxy) {
console.log('Cannot sync configurations - NetworkProxy not initialized');
return;
}
try {
// Get SSL certificates from assets
// Import fs directly since it's not in plugins
const fs = await import('fs');
let certPair;
try {
certPair = {
key: fs.readFileSync('assets/certs/key.pem', 'utf8'),
cert: fs.readFileSync('assets/certs/cert.pem', 'utf8'),
};
} catch (certError) {
console.log(`Warning: Could not read default certificates: ${certError}`);
console.log(
'Using empty certificate placeholders - ACME will generate proper certificates if enabled'
);
// Use empty placeholders - NetworkProxy will use its internal defaults
// or ACME will generate proper ones if enabled
certPair = {
key: '',
cert: '',
};
}
// Convert domain configs to NetworkProxy configs
const proxyConfigs = this.networkProxy.convertPortProxyConfigs(
this.settings.domainConfigs,
certPair
);
// Log ACME-eligible domains if ACME is enabled
if (this.settings.acme?.enabled) {
const acmeEligibleDomains = proxyConfigs
.filter((config) => !config.hostName.includes('*')) // Exclude wildcards
.map((config) => config.hostName);
if (acmeEligibleDomains.length > 0) {
console.log(`Domains eligible for ACME certificates: ${acmeEligibleDomains.join(', ')}`);
} else {
console.log('No domains eligible for ACME certificates found in configuration');
}
}
// Update NetworkProxy with the converted configs
await this.networkProxy.updateProxyConfigs(proxyConfigs);
console.log(`Successfully synchronized ${proxyConfigs.length} domain configurations to NetworkProxy`);
} catch (err) {
console.log(`Failed to sync configurations: ${err}`);
}
}
/**
* Request a certificate for a specific domain
*/
public async requestCertificate(domain: string): Promise<boolean> {
if (!this.networkProxy) {
console.log('Cannot request certificate - NetworkProxy not initialized');
return false;
}
if (!this.settings.acme?.enabled) {
console.log('Cannot request certificate - ACME is not enabled');
return false;
}
try {
const result = await this.networkProxy.requestCertificate(domain);
if (result) {
console.log(`Certificate request for ${domain} submitted successfully`);
} else {
console.log(`Certificate request for ${domain} failed`);
}
return result;
} catch (err) {
console.log(`Error requesting certificate: ${err}`);
return false;
}
}
}

344
ts/classes.pp.portproxy.ts Normal file
View File

@ -0,0 +1,344 @@
import * as plugins from './plugins.js';
import type { IPortProxySettings, IDomainConfig } from './classes.pp.interfaces.js';
import { ConnectionManager } from './classes.pp.connectionmanager.js';
import { SecurityManager } from './classes.pp.securitymanager.js';
import { DomainConfigManager } from './classes.pp.domainconfigmanager.js';
import { TlsManager } from './classes.pp.tlsmanager.js';
import { NetworkProxyBridge } from './classes.pp.networkproxybridge.js';
import { TimeoutManager } from './classes.pp.timeoutmanager.js';
import { AcmeManager } from './classes.pp.acmemanager.js';
import { PortRangeManager } from './classes.pp.portrangemanager.js';
import { ConnectionHandler } from './classes.pp.connectionhandler.js';
/**
* PortProxy - Main class that coordinates all components
*/
export class PortProxy {
private netServers: plugins.net.Server[] = [];
private connectionLogger: NodeJS.Timeout | null = null;
private isShuttingDown: boolean = false;
// Component managers
private connectionManager: ConnectionManager;
private securityManager: SecurityManager;
public domainConfigManager: DomainConfigManager;
private tlsManager: TlsManager;
private networkProxyBridge: NetworkProxyBridge;
private timeoutManager: TimeoutManager;
private acmeManager: AcmeManager;
private portRangeManager: PortRangeManager;
private connectionHandler: ConnectionHandler;
constructor(settingsArg: IPortProxySettings) {
// Set reasonable defaults for all settings
this.settings = {
...settingsArg,
targetIP: settingsArg.targetIP || 'localhost',
initialDataTimeout: settingsArg.initialDataTimeout || 120000,
socketTimeout: settingsArg.socketTimeout || 3600000,
inactivityCheckInterval: settingsArg.inactivityCheckInterval || 60000,
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 86400000,
inactivityTimeout: settingsArg.inactivityTimeout || 14400000,
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000,
noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true,
keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true,
keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 10000,
maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024,
disableInactivityCheck: settingsArg.disableInactivityCheck || false,
enableKeepAliveProbes:
settingsArg.enableKeepAliveProbes !== undefined ? settingsArg.enableKeepAliveProbes : true,
enableDetailedLogging: settingsArg.enableDetailedLogging || false,
enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false,
enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false,
allowSessionTicket:
settingsArg.allowSessionTicket !== undefined ? settingsArg.allowSessionTicket : true,
maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100,
connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300,
keepAliveTreatment: settingsArg.keepAliveTreatment || 'extended',
keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6,
extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000,
networkProxyPort: settingsArg.networkProxyPort || 8443,
acme: settingsArg.acme || {
enabled: false,
port: 80,
contactEmail: 'admin@example.com',
useProduction: false,
renewThresholdDays: 30,
autoRenew: true,
certificateStore: './certs',
skipConfiguredCerts: false,
},
};
// Initialize component managers
this.timeoutManager = new TimeoutManager(this.settings);
this.securityManager = new SecurityManager(this.settings);
this.connectionManager = new ConnectionManager(
this.settings,
this.securityManager,
this.timeoutManager
);
this.domainConfigManager = new DomainConfigManager(this.settings);
this.tlsManager = new TlsManager(this.settings);
this.networkProxyBridge = new NetworkProxyBridge(this.settings);
this.portRangeManager = new PortRangeManager(this.settings);
this.acmeManager = new AcmeManager(this.settings, this.networkProxyBridge);
// Initialize connection handler
this.connectionHandler = new ConnectionHandler(
this.settings,
this.connectionManager,
this.securityManager,
this.domainConfigManager,
this.tlsManager,
this.networkProxyBridge,
this.timeoutManager,
this.portRangeManager
);
}
/**
* The settings for the port proxy
*/
public settings: IPortProxySettings;
/**
* Start the proxy server
*/
public async start() {
// Don't start if already shutting down
if (this.isShuttingDown) {
console.log("Cannot start PortProxy while it's shutting down");
return;
}
// Initialize and start NetworkProxy if needed
if (
this.settings.useNetworkProxy &&
this.settings.useNetworkProxy.length > 0
) {
await this.networkProxyBridge.initialize();
await this.networkProxyBridge.start();
}
// Validate port configuration
const configWarnings = this.portRangeManager.validateConfiguration();
if (configWarnings.length > 0) {
console.log("Port configuration warnings:");
for (const warning of configWarnings) {
console.log(` - ${warning}`);
}
}
// Get listening ports from PortRangeManager
const listeningPorts = this.portRangeManager.getListeningPorts();
// Create servers for each port
for (const port of listeningPorts) {
const server = plugins.net.createServer((socket) => {
// Check if shutting down
if (this.isShuttingDown) {
socket.end();
socket.destroy();
return;
}
// Delegate to connection handler
this.connectionHandler.handleConnection(socket);
}).on('error', (err: Error) => {
console.log(`Server Error on port ${port}: ${err.message}`);
});
server.listen(port, () => {
const isNetworkProxyPort = this.settings.useNetworkProxy?.includes(port);
console.log(
`PortProxy -> OK: Now listening on port ${port}${
this.settings.sniEnabled && !isNetworkProxyPort ? ' (SNI passthrough enabled)' : ''
}${isNetworkProxyPort ? ' (NetworkProxy forwarding enabled)' : ''}`
);
});
this.netServers.push(server);
}
// Set up periodic connection logging and inactivity checks
this.connectionLogger = setInterval(() => {
// Immediately return if shutting down
if (this.isShuttingDown) return;
// Perform inactivity check
this.connectionManager.performInactivityCheck();
// Log connection statistics
const now = Date.now();
let maxIncoming = 0;
let maxOutgoing = 0;
let tlsConnections = 0;
let nonTlsConnections = 0;
let completedTlsHandshakes = 0;
let pendingTlsHandshakes = 0;
let keepAliveConnections = 0;
let networkProxyConnections = 0;
// Get connection records for analysis
const connectionRecords = this.connectionManager.getConnections();
// Analyze active connections
for (const record of connectionRecords.values()) {
// Track connection stats
if (record.isTLS) {
tlsConnections++;
if (record.tlsHandshakeComplete) {
completedTlsHandshakes++;
} else {
pendingTlsHandshakes++;
}
} else {
nonTlsConnections++;
}
if (record.hasKeepAlive) {
keepAliveConnections++;
}
if (record.usingNetworkProxy) {
networkProxyConnections++;
}
maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
if (record.outgoingStartTime) {
maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
}
}
// Get termination stats
const terminationStats = this.connectionManager.getTerminationStats();
// Log detailed stats
console.log(
`Active connections: ${connectionRecords.size}. ` +
`Types: TLS=${tlsConnections} (Completed=${completedTlsHandshakes}, Pending=${pendingTlsHandshakes}), ` +
`Non-TLS=${nonTlsConnections}, KeepAlive=${keepAliveConnections}, NetworkProxy=${networkProxyConnections}. ` +
`Longest running: IN=${plugins.prettyMs(maxIncoming)}, OUT=${plugins.prettyMs(maxOutgoing)}. ` +
`Termination stats: ${JSON.stringify({
IN: terminationStats.incoming,
OUT: terminationStats.outgoing,
})}`
);
}, this.settings.inactivityCheckInterval || 60000);
// Make sure the interval doesn't keep the process alive
if (this.connectionLogger.unref) {
this.connectionLogger.unref();
}
}
/**
* Stop the proxy server
*/
public async stop() {
console.log('PortProxy shutting down...');
this.isShuttingDown = true;
// Stop accepting new connections
const closeServerPromises: Promise<void>[] = this.netServers.map(
(server) =>
new Promise<void>((resolve) => {
if (!server.listening) {
resolve();
return;
}
server.close((err) => {
if (err) {
console.log(`Error closing server: ${err.message}`);
}
resolve();
});
})
);
// Stop the connection logger
if (this.connectionLogger) {
clearInterval(this.connectionLogger);
this.connectionLogger = null;
}
// Wait for servers to close
await Promise.all(closeServerPromises);
console.log('All servers closed. Cleaning up active connections...');
// Clean up all active connections
this.connectionManager.clearConnections();
// Stop NetworkProxy
await this.networkProxyBridge.stop();
// Clear all servers
this.netServers = [];
console.log('PortProxy shutdown complete.');
}
/**
* Updates the domain configurations for the proxy
*/
public async updateDomainConfigs(newDomainConfigs: IDomainConfig[]): Promise<void> {
console.log(`Updating domain configurations (${newDomainConfigs.length} configs)`);
// Update domain configs in DomainConfigManager
this.domainConfigManager.updateDomainConfigs(newDomainConfigs);
// If NetworkProxy is initialized, resync the configurations
if (this.networkProxyBridge.getNetworkProxy()) {
await this.networkProxyBridge.syncDomainConfigsToNetworkProxy();
}
}
/**
* Updates the ACME certificate settings
*/
public async updateAcmeSettings(acmeSettings: IPortProxySettings['acme']): Promise<void> {
console.log('Updating ACME certificate settings');
// Delegate to AcmeManager
await this.acmeManager.updateAcmeSettings(acmeSettings);
}
/**
* Requests a certificate for a specific domain
*/
public async requestCertificate(domain: string): Promise<boolean> {
// Delegate to AcmeManager
return this.acmeManager.requestCertificate(domain);
}
/**
* Get statistics about current connections
*/
public getStatistics(): any {
const connectionRecords = this.connectionManager.getConnections();
const terminationStats = this.connectionManager.getTerminationStats();
let tlsConnections = 0;
let nonTlsConnections = 0;
let keepAliveConnections = 0;
let networkProxyConnections = 0;
// Analyze active connections
for (const record of connectionRecords.values()) {
if (record.isTLS) tlsConnections++;
else nonTlsConnections++;
if (record.hasKeepAlive) keepAliveConnections++;
if (record.usingNetworkProxy) networkProxyConnections++;
}
return {
activeConnections: connectionRecords.size,
tlsConnections,
nonTlsConnections,
keepAliveConnections,
networkProxyConnections,
terminationStats
};
}
}

View File

@ -0,0 +1,214 @@
import type{ IPortProxySettings } from './classes.pp.interfaces.js';
/**
* Manages port ranges and port-based configuration
*/
export class PortRangeManager {
constructor(private settings: IPortProxySettings) {}
/**
* Get all ports that should be listened on
*/
public getListeningPorts(): Set<number> {
const listeningPorts = new Set<number>();
// Always include the main fromPort
listeningPorts.add(this.settings.fromPort);
// Add ports from global port ranges if defined
if (this.settings.globalPortRanges && this.settings.globalPortRanges.length > 0) {
for (const range of this.settings.globalPortRanges) {
for (let port = range.from; port <= range.to; port++) {
listeningPorts.add(port);
}
}
}
return listeningPorts;
}
/**
* Check if a port should use NetworkProxy for forwarding
*/
public shouldUseNetworkProxy(port: number): boolean {
return !!this.settings.useNetworkProxy && this.settings.useNetworkProxy.includes(port);
}
/**
* Check if port should use global forwarding
*/
public shouldUseGlobalForwarding(port: number): boolean {
return (
!!this.settings.forwardAllGlobalRanges &&
this.isPortInGlobalRanges(port)
);
}
/**
* Check if a port is in global ranges
*/
public isPortInGlobalRanges(port: number): boolean {
return (
this.settings.globalPortRanges &&
this.isPortInRanges(port, this.settings.globalPortRanges)
);
}
/**
* Check if a port falls within the specified ranges
*/
public isPortInRanges(port: number, ranges: Array<{ from: number; to: number }>): boolean {
return ranges.some((range) => port >= range.from && port <= range.to);
}
/**
* Get forwarding port for a specific listening port
* This determines what port to connect to on the target
*/
public getForwardingPort(listeningPort: number): number {
// If using global forwarding, forward to the original port
if (this.settings.forwardAllGlobalRanges && this.isPortInGlobalRanges(listeningPort)) {
return listeningPort;
}
// Otherwise use the configured toPort
return this.settings.toPort;
}
/**
* Find domain-specific port ranges that include a given port
*/
public findDomainPortRange(port: number): {
domainIndex: number,
range: { from: number, to: number }
} | undefined {
for (let i = 0; i < this.settings.domainConfigs.length; i++) {
const domain = this.settings.domainConfigs[i];
if (domain.portRanges) {
for (const range of domain.portRanges) {
if (port >= range.from && port <= range.to) {
return { domainIndex: i, range };
}
}
}
}
return undefined;
}
/**
* Get a list of all configured ports
* This includes the fromPort, NetworkProxy ports, and ports from all ranges
*/
public getAllConfiguredPorts(): number[] {
const ports = new Set<number>();
// Add main listening port
ports.add(this.settings.fromPort);
// Add NetworkProxy port if configured
if (this.settings.networkProxyPort) {
ports.add(this.settings.networkProxyPort);
}
// Add NetworkProxy ports
if (this.settings.useNetworkProxy) {
for (const port of this.settings.useNetworkProxy) {
ports.add(port);
}
}
// Add ACME HTTP challenge port if enabled
if (this.settings.acme?.enabled && this.settings.acme.port) {
ports.add(this.settings.acme.port);
}
// Add global port ranges
if (this.settings.globalPortRanges) {
for (const range of this.settings.globalPortRanges) {
for (let port = range.from; port <= range.to; port++) {
ports.add(port);
}
}
}
// Add domain-specific port ranges
for (const domain of this.settings.domainConfigs) {
if (domain.portRanges) {
for (const range of domain.portRanges) {
for (let port = range.from; port <= range.to; port++) {
ports.add(port);
}
}
}
// Add domain-specific NetworkProxy port if configured
if (domain.useNetworkProxy && domain.networkProxyPort) {
ports.add(domain.networkProxyPort);
}
}
return Array.from(ports);
}
/**
* Validate port configuration
* Returns array of warning messages
*/
public validateConfiguration(): string[] {
const warnings: string[] = [];
// Check for overlapping port ranges
const portMappings = new Map<number, string[]>();
// Track global port ranges
if (this.settings.globalPortRanges) {
for (const range of this.settings.globalPortRanges) {
for (let port = range.from; port <= range.to; port++) {
if (!portMappings.has(port)) {
portMappings.set(port, []);
}
portMappings.get(port)!.push('Global Port Range');
}
}
}
// Track domain-specific port ranges
for (const domain of this.settings.domainConfigs) {
if (domain.portRanges) {
for (const range of domain.portRanges) {
for (let port = range.from; port <= range.to; port++) {
if (!portMappings.has(port)) {
portMappings.set(port, []);
}
portMappings.get(port)!.push(`Domain: ${domain.domains.join(', ')}`);
}
}
}
}
// Check for ports with multiple mappings
for (const [port, mappings] of portMappings.entries()) {
if (mappings.length > 1) {
warnings.push(`Port ${port} has multiple mappings: ${mappings.join(', ')}`);
}
}
// Check if main ports are used elsewhere
if (portMappings.has(this.settings.fromPort) && portMappings.get(this.settings.fromPort)!.length > 0) {
warnings.push(`Main listening port ${this.settings.fromPort} is also used in port ranges`);
}
if (this.settings.networkProxyPort && portMappings.has(this.settings.networkProxyPort)) {
warnings.push(`NetworkProxy port ${this.settings.networkProxyPort} is also used in port ranges`);
}
// Check ACME port
if (this.settings.acme?.enabled && this.settings.acme.port) {
if (portMappings.has(this.settings.acme.port)) {
warnings.push(`ACME HTTP challenge port ${this.settings.acme.port} is also used in port ranges`);
}
}
return warnings;
}
}

View File

@ -0,0 +1,147 @@
import * as plugins from './plugins.js';
import type { IPortProxySettings } from './classes.pp.interfaces.js';
/**
* Handles security aspects like IP tracking, rate limiting, and authorization
*/
export class SecurityManager {
private connectionsByIP: Map<string, Set<string>> = new Map();
private connectionRateByIP: Map<string, number[]> = new Map();
constructor(private settings: IPortProxySettings) {}
/**
* Get connections count by IP
*/
public getConnectionCountByIP(ip: string): number {
return this.connectionsByIP.get(ip)?.size || 0;
}
/**
* Check and update connection rate for an IP
* @returns true if within rate limit, false if exceeding limit
*/
public checkConnectionRate(ip: string): boolean {
const now = Date.now();
const minute = 60 * 1000;
if (!this.connectionRateByIP.has(ip)) {
this.connectionRateByIP.set(ip, [now]);
return true;
}
// Get timestamps and filter out entries older than 1 minute
const timestamps = this.connectionRateByIP.get(ip)!.filter((time) => now - time < minute);
timestamps.push(now);
this.connectionRateByIP.set(ip, timestamps);
// Check if rate exceeds limit
return timestamps.length <= this.settings.connectionRateLimitPerMinute!;
}
/**
* Track connection by IP
*/
public trackConnectionByIP(ip: string, connectionId: string): void {
if (!this.connectionsByIP.has(ip)) {
this.connectionsByIP.set(ip, new Set());
}
this.connectionsByIP.get(ip)!.add(connectionId);
}
/**
* Remove connection tracking for an IP
*/
public removeConnectionByIP(ip: string, connectionId: string): void {
if (this.connectionsByIP.has(ip)) {
const connections = this.connectionsByIP.get(ip)!;
connections.delete(connectionId);
if (connections.size === 0) {
this.connectionsByIP.delete(ip);
}
}
}
/**
* Check if an IP is allowed using glob patterns
*/
public isIPAuthorized(ip: string, allowedIPs: string[], blockedIPs: string[] = []): boolean {
// Skip IP validation if allowedIPs is empty
if (!ip || (allowedIPs.length === 0 && blockedIPs.length === 0)) {
return true;
}
// First check if IP is blocked
if (blockedIPs.length > 0 && this.isGlobIPMatch(ip, blockedIPs)) {
return false;
}
// Then check if IP is allowed
return this.isGlobIPMatch(ip, allowedIPs);
}
/**
* Check if the IP matches any of the glob patterns
*/
private isGlobIPMatch(ip: string, patterns: string[]): boolean {
if (!ip || !patterns || patterns.length === 0) return false;
const normalizeIP = (ip: string): string[] => {
if (!ip) return [];
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);
if (normalizedIPVariants.length === 0) return false;
const expandedPatterns = patterns.flatMap(normalizeIP);
return normalizedIPVariants.some((ipVariant) =>
expandedPatterns.some((pattern) => plugins.minimatch(ipVariant, pattern))
);
}
/**
* Check if IP should be allowed considering connection rate and max connections
* @returns Object with result and reason
*/
public validateIP(ip: string): { allowed: boolean; reason?: string } {
// Check connection count limit
if (
this.settings.maxConnectionsPerIP &&
this.getConnectionCountByIP(ip) >= this.settings.maxConnectionsPerIP
) {
return {
allowed: false,
reason: `Maximum connections per IP (${this.settings.maxConnectionsPerIP}) exceeded`
};
}
// Check connection rate limit
if (
this.settings.connectionRateLimitPerMinute &&
!this.checkConnectionRate(ip)
) {
return {
allowed: false,
reason: `Connection rate limit (${this.settings.connectionRateLimitPerMinute}/min) exceeded`
};
}
return { allowed: true };
}
/**
* Clears all IP tracking data (for shutdown)
*/
public clearIPTracking(): void {
this.connectionsByIP.clear();
this.connectionRateByIP.clear();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,190 @@
import type { IConnectionRecord, IPortProxySettings } from './classes.pp.interfaces.js';
/**
* Manages timeouts and inactivity tracking for connections
*/
export class TimeoutManager {
constructor(private settings: IPortProxySettings) {}
/**
* Ensure timeout values don't exceed Node.js max safe integer
*/
public ensureSafeTimeout(timeout: number): number {
const MAX_SAFE_TIMEOUT = 2147483647; // Maximum safe value (2^31 - 1)
return Math.min(Math.floor(timeout), MAX_SAFE_TIMEOUT);
}
/**
* Generate a slightly randomized timeout to prevent thundering herd
*/
public randomizeTimeout(baseTimeout: number, variationPercent: number = 5): number {
const safeBaseTimeout = this.ensureSafeTimeout(baseTimeout);
const variation = safeBaseTimeout * (variationPercent / 100);
return this.ensureSafeTimeout(
safeBaseTimeout + Math.floor(Math.random() * variation * 2) - variation
);
}
/**
* Update connection activity timestamp
*/
public updateActivity(record: IConnectionRecord): void {
record.lastActivity = Date.now();
// Clear any inactivity warning
if (record.inactivityWarningIssued) {
record.inactivityWarningIssued = false;
}
}
/**
* Calculate effective inactivity timeout based on connection type
*/
public getEffectiveInactivityTimeout(record: IConnectionRecord): number {
let effectiveTimeout = this.settings.inactivityTimeout || 14400000; // 4 hours default
// For immortal keep-alive connections, use an extremely long timeout
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
return Number.MAX_SAFE_INTEGER;
}
// For extended keep-alive connections, apply multiplier
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
const multiplier = this.settings.keepAliveInactivityMultiplier || 6;
effectiveTimeout = effectiveTimeout * multiplier;
}
return this.ensureSafeTimeout(effectiveTimeout);
}
/**
* Calculate effective max lifetime based on connection type
*/
public getEffectiveMaxLifetime(record: IConnectionRecord): number {
// Use domain-specific timeout if available
const baseTimeout = record.domainConfig?.connectionTimeout ||
this.settings.maxConnectionLifetime ||
86400000; // 24 hours default
// For immortal keep-alive connections, use an extremely long lifetime
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
return Number.MAX_SAFE_INTEGER;
}
// For extended keep-alive connections, use the extended lifetime setting
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
return this.ensureSafeTimeout(
this.settings.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000 // 7 days default
);
}
// Apply randomization if enabled
if (this.settings.enableRandomizedTimeouts) {
return this.randomizeTimeout(baseTimeout);
}
return this.ensureSafeTimeout(baseTimeout);
}
/**
* Setup connection timeout
* @returns The cleanup timer
*/
public setupConnectionTimeout(
record: IConnectionRecord,
onTimeout: (record: IConnectionRecord, reason: string) => void
): NodeJS.Timeout {
// Clear any existing timer
if (record.cleanupTimer) {
clearTimeout(record.cleanupTimer);
}
// Calculate effective timeout
const effectiveLifetime = this.getEffectiveMaxLifetime(record);
// Set up the timeout
const timer = setTimeout(() => {
// Call the provided callback
onTimeout(record, 'connection_timeout');
}, effectiveLifetime);
// Make sure timeout doesn't keep the process alive
if (timer.unref) {
timer.unref();
}
return timer;
}
/**
* Check for inactivity on a connection
* @returns Object with check results
*/
public checkInactivity(record: IConnectionRecord): {
isInactive: boolean;
shouldWarn: boolean;
inactivityTime: number;
effectiveTimeout: number;
} {
// Skip for connections with inactivity check disabled
if (this.settings.disableInactivityCheck) {
return {
isInactive: false,
shouldWarn: false,
inactivityTime: 0,
effectiveTimeout: 0
};
}
// Skip for immortal keep-alive connections
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
return {
isInactive: false,
shouldWarn: false,
inactivityTime: 0,
effectiveTimeout: 0
};
}
const now = Date.now();
const inactivityTime = now - record.lastActivity;
const effectiveTimeout = this.getEffectiveInactivityTimeout(record);
// Check if inactive
const isInactive = inactivityTime > effectiveTimeout;
// For keep-alive connections, we should warn first
const shouldWarn = record.hasKeepAlive &&
isInactive &&
!record.inactivityWarningIssued;
return {
isInactive,
shouldWarn,
inactivityTime,
effectiveTimeout
};
}
/**
* Apply socket timeout settings
*/
public applySocketTimeouts(record: IConnectionRecord): void {
// Skip for immortal keep-alive connections
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
// Disable timeouts completely for immortal connections
record.incoming.setTimeout(0);
if (record.outgoing) {
record.outgoing.setTimeout(0);
}
return;
}
// Apply normal timeouts
const timeout = this.ensureSafeTimeout(this.settings.socketTimeout || 3600000); // 1 hour default
record.incoming.setTimeout(timeout);
if (record.outgoing) {
record.outgoing.setTimeout(timeout);
}
}
}

206
ts/classes.pp.tlsmanager.ts Normal file
View File

@ -0,0 +1,206 @@
import * as plugins from './plugins.js';
import type { IPortProxySettings } from './classes.pp.interfaces.js';
import { SniHandler } from './classes.pp.snihandler.js';
/**
* Interface for connection information used for SNI extraction
*/
interface IConnectionInfo {
sourceIp: string;
sourcePort: number;
destIp: string;
destPort: number;
}
/**
* Manages TLS-related operations including SNI extraction and validation
*/
export class TlsManager {
constructor(private settings: IPortProxySettings) {}
/**
* Check if a data chunk appears to be a TLS handshake
*/
public isTlsHandshake(chunk: Buffer): boolean {
return SniHandler.isTlsHandshake(chunk);
}
/**
* Check if a data chunk appears to be a TLS ClientHello
*/
public isClientHello(chunk: Buffer): boolean {
return SniHandler.isClientHello(chunk);
}
/**
* Extract Server Name Indication (SNI) from TLS handshake
*/
public extractSNI(
chunk: Buffer,
connInfo: IConnectionInfo,
previousDomain?: string
): string | undefined {
// Use the SniHandler to process the TLS packet
return SniHandler.processTlsPacket(
chunk,
connInfo,
this.settings.enableTlsDebugLogging || false,
previousDomain
);
}
/**
* Handle session resumption attempts
*/
public handleSessionResumption(
chunk: Buffer,
connectionId: string,
hasSNI: boolean
): { shouldBlock: boolean; reason?: string } {
// Skip if session tickets are allowed
if (this.settings.allowSessionTicket !== false) {
return { shouldBlock: false };
}
// Check for session resumption attempt
const resumptionInfo = SniHandler.hasSessionResumption(
chunk,
this.settings.enableTlsDebugLogging || false
);
// If this is a resumption attempt without SNI, block it
if (resumptionInfo.isResumption && !hasSNI && !resumptionInfo.hasSNI) {
if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] Session resumption detected without SNI and allowSessionTicket=false. ` +
`Terminating connection to force new TLS handshake.`
);
}
return {
shouldBlock: true,
reason: 'session_ticket_blocked'
};
}
return { shouldBlock: false };
}
/**
* Check for SNI mismatch during renegotiation
*/
public checkRenegotiationSNI(
chunk: Buffer,
connInfo: IConnectionInfo,
expectedDomain: string,
connectionId: string
): { hasMismatch: boolean; extractedSNI?: string } {
// Only process if this looks like a TLS ClientHello
if (!this.isClientHello(chunk)) {
return { hasMismatch: false };
}
try {
// Extract SNI with renegotiation support
const newSNI = SniHandler.extractSNIWithResumptionSupport(
chunk,
connInfo,
this.settings.enableTlsDebugLogging || false
);
// Skip if no SNI was found
if (!newSNI) return { hasMismatch: false };
// Check for SNI mismatch
if (newSNI !== expectedDomain) {
if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] Renegotiation with different SNI: ${expectedDomain} -> ${newSNI}. ` +
`Terminating connection - SNI domain switching is not allowed.`
);
}
return { hasMismatch: true, extractedSNI: newSNI };
} else if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] Renegotiation detected with same SNI: ${newSNI}. Allowing.`
);
}
} catch (err) {
console.log(
`[${connectionId}] Error processing ClientHello: ${err}. Allowing connection to continue.`
);
}
return { hasMismatch: false };
}
/**
* Create a renegotiation handler function for a connection
*/
public createRenegotiationHandler(
connectionId: string,
lockedDomain: string,
connInfo: IConnectionInfo,
onMismatch: (connectionId: string, reason: string) => void
): (chunk: Buffer) => void {
return (chunk: Buffer) => {
const result = this.checkRenegotiationSNI(chunk, connInfo, lockedDomain, connectionId);
if (result.hasMismatch) {
onMismatch(connectionId, 'sni_mismatch');
}
};
}
/**
* Analyze TLS connection for browser fingerprinting
* This helps identify browser vs non-browser connections
*/
public analyzeClientHello(chunk: Buffer): {
isBrowserConnection: boolean;
isRenewal: boolean;
hasSNI: boolean;
} {
// Default result
const result = {
isBrowserConnection: false,
isRenewal: false,
hasSNI: false
};
try {
// Check if it's a ClientHello
if (!this.isClientHello(chunk)) {
return result;
}
// Check for session resumption
const resumptionInfo = SniHandler.hasSessionResumption(
chunk,
this.settings.enableTlsDebugLogging || false
);
// Extract SNI
const sni = SniHandler.extractSNI(
chunk,
this.settings.enableTlsDebugLogging || false
);
// Update result
result.isRenewal = resumptionInfo.isResumption;
result.hasSNI = !!sni;
// Browsers typically:
// 1. Send SNI extension
// 2. Have a variety of extensions (ALPN, etc.)
// 3. Use standard cipher suites
// ...more complex heuristics could be implemented here
// Simple heuristic: presence of SNI suggests browser
result.isBrowserConnection = !!sni;
return result;
} catch (err) {
console.log(`Error analyzing ClientHello: ${err}`);
return result;
}
}
}

View File

@ -1,6 +1,6 @@
export * from './classes.iptablesproxy.js';
export * from './classes.networkproxy.js';
export * from './classes.portproxy.js';
export * from './classes.pp.portproxy.js';
export * from './classes.port80handler.js';
export * from './classes.sslredirect.js';
export * from './classes.snihandler.js';
export * from './classes.pp.snihandler.js';