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
This commit is contained in:
parent
985031e9ac
commit
e2ee673197
12
changelog.md
12
changelog.md
@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
|
@ -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');
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '3.41.8',
|
||||
version: '4.0.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
149
ts/classes.pp.acmemanager.ts
Normal file
149
ts/classes.pp.acmemanager.ts
Normal 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;
|
||||
}
|
||||
}
|
982
ts/classes.pp.connectionhandler.ts
Normal file
982
ts/classes.pp.connectionhandler.ts
Normal 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
446
ts/classes.pp.connectionmanager.ts
Normal file
446
ts/classes.pp.connectionmanager.ts
Normal 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);
|
||||
}
|
||||
}
|
123
ts/classes.pp.domainconfigmanager.ts
Normal file
123
ts/classes.pp.domainconfigmanager.ts
Normal 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
136
ts/classes.pp.interfaces.ts
Normal 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
|
||||
}
|
258
ts/classes.pp.networkproxybridge.ts
Normal file
258
ts/classes.pp.networkproxybridge.ts
Normal 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
344
ts/classes.pp.portproxy.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
214
ts/classes.pp.portrangemanager.ts
Normal file
214
ts/classes.pp.portrangemanager.ts
Normal 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;
|
||||
}
|
||||
}
|
147
ts/classes.pp.securitymanager.ts
Normal file
147
ts/classes.pp.securitymanager.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -22,114 +22,6 @@ export class SniHandler {
|
||||
private static fragmentedBuffers: Map<string, Buffer> = new Map();
|
||||
private static fragmentTimeout: number = 1000; // ms to wait for fragments before cleanup
|
||||
|
||||
// Session tracking for tab reactivation scenarios
|
||||
private static sessionCache: Map<
|
||||
string,
|
||||
{
|
||||
sni: string;
|
||||
timestamp: number;
|
||||
clientRandom?: Buffer;
|
||||
}
|
||||
> = new Map();
|
||||
|
||||
// Longer timeout for session cache (24 hours by default)
|
||||
private static sessionCacheTimeout: number = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
|
||||
|
||||
// Cleanup interval for session cache (run every hour)
|
||||
private static sessionCleanupInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the session cache cleanup mechanism.
|
||||
* This should be called during application startup.
|
||||
*/
|
||||
public static initSessionCacheCleanup(): void {
|
||||
if (this.sessionCleanupInterval === null) {
|
||||
this.sessionCleanupInterval = setInterval(() => {
|
||||
this.cleanupSessionCache();
|
||||
}, 60 * 60 * 1000); // Run every hour
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired entries from the session cache
|
||||
*/
|
||||
private static cleanupSessionCache(): void {
|
||||
const now = Date.now();
|
||||
const expiredKeys: string[] = [];
|
||||
|
||||
this.sessionCache.forEach((session, key) => {
|
||||
if (now - session.timestamp > this.sessionCacheTimeout) {
|
||||
expiredKeys.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
expiredKeys.forEach((key) => {
|
||||
this.sessionCache.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a client identity key for session tracking
|
||||
* Uses source IP and optional client random for uniqueness
|
||||
*
|
||||
* @param sourceIp - Client IP address
|
||||
* @param clientRandom - Optional TLS client random value
|
||||
* @returns A string key for the session cache
|
||||
*/
|
||||
private static createClientKey(sourceIp: string, clientRandom?: Buffer): string {
|
||||
if (clientRandom) {
|
||||
// If we have the client random, use it for more precise tracking
|
||||
return `${sourceIp}:${clientRandom.toString('hex')}`;
|
||||
}
|
||||
// Fall back to just IP-based tracking
|
||||
return sourceIp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store SNI information in the session cache
|
||||
*
|
||||
* @param sourceIp - Client IP address
|
||||
* @param sni - The extracted SNI value
|
||||
* @param clientRandom - Optional TLS client random value
|
||||
*/
|
||||
private static cacheSession(sourceIp: string, sni: string, clientRandom?: Buffer): void {
|
||||
const key = this.createClientKey(sourceIp, clientRandom);
|
||||
this.sessionCache.set(key, {
|
||||
sni,
|
||||
timestamp: Date.now(),
|
||||
clientRandom,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve SNI information from the session cache
|
||||
*
|
||||
* @param sourceIp - Client IP address
|
||||
* @param clientRandom - Optional TLS client random value
|
||||
* @returns The cached SNI or undefined if not found
|
||||
*/
|
||||
private static getCachedSession(sourceIp: string, clientRandom?: Buffer): string | undefined {
|
||||
// Try with client random first for precision
|
||||
if (clientRandom) {
|
||||
const preciseKey = this.createClientKey(sourceIp, clientRandom);
|
||||
const preciseSession = this.sessionCache.get(preciseKey);
|
||||
if (preciseSession) {
|
||||
return preciseSession.sni;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to IP-only lookup
|
||||
const ipKey = this.createClientKey(sourceIp);
|
||||
const session = this.sessionCache.get(ipKey);
|
||||
if (session) {
|
||||
// Update the timestamp to keep the session alive
|
||||
session.timestamp = Date.now();
|
||||
return session.sni;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the client random value from a ClientHello message
|
||||
*
|
||||
@ -1172,7 +1064,6 @@ export class SniHandler {
|
||||
* 4. Fragmented ClientHello messages
|
||||
* 5. TLS 1.3 Early Data (0-RTT)
|
||||
* 6. Chrome's connection racing behaviors
|
||||
* 7. Tab reactivation patterns with session cache
|
||||
*
|
||||
* @param buffer - The buffer containing the TLS ClientHello message
|
||||
* @param connectionInfo - Optional connection information for fragment handling
|
||||
@ -1235,19 +1126,10 @@ export class SniHandler {
|
||||
const standardSni = this.extractSNI(processBuffer, enableLogging);
|
||||
if (standardSni) {
|
||||
log(`Found standard SNI: ${standardSni}`);
|
||||
|
||||
// If we extracted a standard SNI, cache it for future use
|
||||
if (connectionInfo?.sourceIp) {
|
||||
const clientRandom = this.extractClientRandom(processBuffer);
|
||||
this.cacheSession(connectionInfo.sourceIp, standardSni, clientRandom);
|
||||
log(`Cached SNI for future reference: ${standardSni}`);
|
||||
}
|
||||
|
||||
return standardSni;
|
||||
}
|
||||
|
||||
// Check for session resumption when standard SNI extraction fails
|
||||
// This may help in chained proxy scenarios
|
||||
if (this.isClientHello(processBuffer)) {
|
||||
const resumptionInfo = this.hasSessionResumption(processBuffer, enableLogging);
|
||||
|
||||
@ -1258,31 +1140,11 @@ export class SniHandler {
|
||||
const pskSni = this.extractSNIFromPSKExtension(processBuffer, enableLogging);
|
||||
if (pskSni) {
|
||||
log(`Extracted SNI from PSK extension: ${pskSni}`);
|
||||
|
||||
// Cache this SNI
|
||||
if (connectionInfo?.sourceIp) {
|
||||
const clientRandom = this.extractClientRandom(processBuffer);
|
||||
this.cacheSession(connectionInfo.sourceIp, pskSni, clientRandom);
|
||||
}
|
||||
|
||||
return pskSni;
|
||||
}
|
||||
|
||||
// If session resumption has SNI in a non-standard location,
|
||||
// we need to apply heuristics
|
||||
if (connectionInfo?.sourceIp) {
|
||||
const cachedSni = this.getCachedSession(connectionInfo.sourceIp);
|
||||
if (cachedSni) {
|
||||
log(`Using cached SNI for session resumption: ${cachedSni}`);
|
||||
return cachedSni;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try tab reactivation and other recovery methods...
|
||||
// (existing code remains unchanged)
|
||||
|
||||
// Log detailed info about the ClientHello when SNI extraction fails
|
||||
if (this.isClientHello(processBuffer) && enableLogging) {
|
||||
log(`SNI extraction failed for ClientHello. Buffer details:`);
|
||||
@ -1303,7 +1165,6 @@ export class SniHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Existing code for fallback methods continues...
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -1313,7 +1174,7 @@ export class SniHandler {
|
||||
*
|
||||
* The method uses connection tracking to handle fragmented ClientHello
|
||||
* messages and various TLS 1.3 behaviors, including Chrome's connection
|
||||
* racing patterns and tab reactivation behaviors.
|
||||
* racing patterns.
|
||||
*
|
||||
* @param buffer - The buffer containing TLS data
|
||||
* @param connectionInfo - Connection metadata (IPs and ports)
|
||||
@ -1321,7 +1182,6 @@ export class SniHandler {
|
||||
* @param cachedSni - Optional cached SNI from previous connections (for racing detection)
|
||||
* @returns The extracted server name or undefined if not found or more data needed
|
||||
*/
|
||||
|
||||
public static processTlsPacket(
|
||||
buffer: Buffer,
|
||||
connectionInfo: {
|
||||
@ -1363,13 +1223,6 @@ export class SniHandler {
|
||||
return cachedSni;
|
||||
}
|
||||
|
||||
// Otherwise check our session cache
|
||||
const sessionCachedSni = this.getCachedSession(connectionInfo.sourceIp);
|
||||
if (sessionCachedSni) {
|
||||
log(`Using session-cached SNI for application data: ${sessionCachedSni}`);
|
||||
return sessionCachedSni;
|
||||
}
|
||||
|
||||
log('Application data packet without cached SNI, cannot determine hostname');
|
||||
return undefined;
|
||||
}
|
||||
@ -1385,9 +1238,6 @@ export class SniHandler {
|
||||
const standardSni = this.extractSNI(buffer, enableLogging);
|
||||
if (standardSni) {
|
||||
log(`Found standard SNI in session resumption: ${standardSni}`);
|
||||
|
||||
// Cache this SNI
|
||||
this.cacheSession(connectionInfo.sourceIp, standardSni);
|
||||
return standardSni;
|
||||
}
|
||||
|
||||
@ -1396,7 +1246,6 @@ export class SniHandler {
|
||||
const pskSni = this.extractSNIFromPSKExtension(buffer, enableLogging);
|
||||
if (pskSni) {
|
||||
log(`Extracted SNI from PSK extension: ${pskSni}`);
|
||||
this.cacheSession(connectionInfo.sourceIp, pskSni);
|
||||
return pskSni;
|
||||
}
|
||||
|
||||
@ -1430,13 +1279,6 @@ export class SniHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// If we still don't have SNI, check for cached sessions
|
||||
const cachedSni = this.getCachedSession(connectionInfo.sourceIp);
|
||||
if (cachedSni) {
|
||||
log(`Using cached SNI for session resumption: ${cachedSni}`);
|
||||
return cachedSni;
|
||||
}
|
||||
|
||||
log(`Session resumption without extractable SNI`);
|
||||
// If allowSessionTicket=false, should be rejected by caller
|
||||
}
|
||||
@ -1451,19 +1293,10 @@ export class SniHandler {
|
||||
}
|
||||
|
||||
// If we couldn't extract an SNI, check if this is a valid ClientHello
|
||||
// If it is, but we couldn't get an SNI, it might be a fragment or
|
||||
// a connection race situation
|
||||
if (this.isClientHello(buffer)) {
|
||||
// Check if we have a cached session for this IP
|
||||
const sessionCachedSni = this.getCachedSession(connectionInfo.sourceIp);
|
||||
if (sessionCachedSni) {
|
||||
log(`Using session cache for ClientHello without SNI: ${sessionCachedSni}`);
|
||||
return sessionCachedSni;
|
||||
}
|
||||
|
||||
log('Valid ClientHello detected, but no SNI extracted - might need more data');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
190
ts/classes.pp.timeoutmanager.ts
Normal file
190
ts/classes.pp.timeoutmanager.ts
Normal 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
206
ts/classes.pp.tlsmanager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
Loading…
x
Reference in New Issue
Block a user