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
|
# 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)
|
## 2025-03-12 - 3.41.8 - fix(portproxy)
|
||||||
Improve TLS handshake timeout handling and connection piping in PortProxy
|
Improve TLS handshake timeout handling and connection piping in PortProxy
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { expect, tap } from '@push.rocks/tapbundle';
|
import { expect, tap } from '@push.rocks/tapbundle';
|
||||||
import * as net from 'net';
|
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 testServer: net.Server;
|
||||||
let portProxy: PortProxy;
|
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
|
// Don't track this proxy as it doesn't actually start or listen
|
||||||
|
|
||||||
const firstTarget = (proxyInstance as any).getTargetIP(domainConfig);
|
const firstTarget = proxyInstance.domainConfigManager.getTargetIP(domainConfig);
|
||||||
const secondTarget = (proxyInstance as any).getTargetIP(domainConfig);
|
const secondTarget = proxyInstance.domainConfigManager.getTargetIP(domainConfig);
|
||||||
expect(firstTarget).toEqual('hostA');
|
expect(firstTarget).toEqual('hostA');
|
||||||
expect(secondTarget).toEqual('hostB');
|
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.
|
// Awaiting the update ensures that the SNI context is added before any requests come in.
|
||||||
await testProxy.updateProxyConfigs([
|
await testProxy.updateProxyConfigs([
|
||||||
{
|
{
|
||||||
destinationIp: '127.0.0.1',
|
destinationIps: ['127.0.0.1'],
|
||||||
destinationPort: '3000',
|
destinationPorts: [3000],
|
||||||
hostName: 'push.rocks',
|
hostName: 'push.rocks',
|
||||||
publicKey: testCertificates.publicKey,
|
publicKey: testCertificates.publicKey,
|
||||||
privateKey: testCertificates.privateKey,
|
privateKey: testCertificates.privateKey,
|
||||||
@ -280,8 +280,8 @@ tap.test('should support WebSocket connections', async () => {
|
|||||||
// Reconfigure proxy with test certificates if necessary
|
// Reconfigure proxy with test certificates if necessary
|
||||||
await testProxy.updateProxyConfigs([
|
await testProxy.updateProxyConfigs([
|
||||||
{
|
{
|
||||||
destinationIp: '127.0.0.1',
|
destinationIps: ['127.0.0.1'],
|
||||||
destinationPort: '3000',
|
destinationPorts: [3000],
|
||||||
hostName: 'push.rocks',
|
hostName: 'push.rocks',
|
||||||
publicKey: testCertificates.publicKey,
|
publicKey: testCertificates.publicKey,
|
||||||
privateKey: testCertificates.privateKey,
|
privateKey: testCertificates.privateKey,
|
||||||
|
@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
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.'
|
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 fragmentedBuffers: Map<string, Buffer> = new Map();
|
||||||
private static fragmentTimeout: number = 1000; // ms to wait for fragments before cleanup
|
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
|
* Extract the client random value from a ClientHello message
|
||||||
*
|
*
|
||||||
@ -1172,7 +1064,6 @@ export class SniHandler {
|
|||||||
* 4. Fragmented ClientHello messages
|
* 4. Fragmented ClientHello messages
|
||||||
* 5. TLS 1.3 Early Data (0-RTT)
|
* 5. TLS 1.3 Early Data (0-RTT)
|
||||||
* 6. Chrome's connection racing behaviors
|
* 6. Chrome's connection racing behaviors
|
||||||
* 7. Tab reactivation patterns with session cache
|
|
||||||
*
|
*
|
||||||
* @param buffer - The buffer containing the TLS ClientHello message
|
* @param buffer - The buffer containing the TLS ClientHello message
|
||||||
* @param connectionInfo - Optional connection information for fragment handling
|
* @param connectionInfo - Optional connection information for fragment handling
|
||||||
@ -1235,19 +1126,10 @@ export class SniHandler {
|
|||||||
const standardSni = this.extractSNI(processBuffer, enableLogging);
|
const standardSni = this.extractSNI(processBuffer, enableLogging);
|
||||||
if (standardSni) {
|
if (standardSni) {
|
||||||
log(`Found standard SNI: ${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;
|
return standardSni;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for session resumption when standard SNI extraction fails
|
// Check for session resumption when standard SNI extraction fails
|
||||||
// This may help in chained proxy scenarios
|
|
||||||
if (this.isClientHello(processBuffer)) {
|
if (this.isClientHello(processBuffer)) {
|
||||||
const resumptionInfo = this.hasSessionResumption(processBuffer, enableLogging);
|
const resumptionInfo = this.hasSessionResumption(processBuffer, enableLogging);
|
||||||
|
|
||||||
@ -1258,30 +1140,10 @@ export class SniHandler {
|
|||||||
const pskSni = this.extractSNIFromPSKExtension(processBuffer, enableLogging);
|
const pskSni = this.extractSNIFromPSKExtension(processBuffer, enableLogging);
|
||||||
if (pskSni) {
|
if (pskSni) {
|
||||||
log(`Extracted SNI from PSK extension: ${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;
|
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
|
// Log detailed info about the ClientHello when SNI extraction fails
|
||||||
if (this.isClientHello(processBuffer) && enableLogging) {
|
if (this.isClientHello(processBuffer) && enableLogging) {
|
||||||
@ -1303,7 +1165,6 @@ export class SniHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Existing code for fallback methods continues...
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1313,7 +1174,7 @@ export class SniHandler {
|
|||||||
*
|
*
|
||||||
* The method uses connection tracking to handle fragmented ClientHello
|
* The method uses connection tracking to handle fragmented ClientHello
|
||||||
* messages and various TLS 1.3 behaviors, including Chrome's connection
|
* 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 buffer - The buffer containing TLS data
|
||||||
* @param connectionInfo - Connection metadata (IPs and ports)
|
* @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)
|
* @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
|
* @returns The extracted server name or undefined if not found or more data needed
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public static processTlsPacket(
|
public static processTlsPacket(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
connectionInfo: {
|
connectionInfo: {
|
||||||
@ -1363,13 +1223,6 @@ export class SniHandler {
|
|||||||
return cachedSni;
|
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');
|
log('Application data packet without cached SNI, cannot determine hostname');
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -1385,9 +1238,6 @@ export class SniHandler {
|
|||||||
const standardSni = this.extractSNI(buffer, enableLogging);
|
const standardSni = this.extractSNI(buffer, enableLogging);
|
||||||
if (standardSni) {
|
if (standardSni) {
|
||||||
log(`Found standard SNI in session resumption: ${standardSni}`);
|
log(`Found standard SNI in session resumption: ${standardSni}`);
|
||||||
|
|
||||||
// Cache this SNI
|
|
||||||
this.cacheSession(connectionInfo.sourceIp, standardSni);
|
|
||||||
return standardSni;
|
return standardSni;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1396,7 +1246,6 @@ export class SniHandler {
|
|||||||
const pskSni = this.extractSNIFromPSKExtension(buffer, enableLogging);
|
const pskSni = this.extractSNIFromPSKExtension(buffer, enableLogging);
|
||||||
if (pskSni) {
|
if (pskSni) {
|
||||||
log(`Extracted SNI from PSK extension: ${pskSni}`);
|
log(`Extracted SNI from PSK extension: ${pskSni}`);
|
||||||
this.cacheSession(connectionInfo.sourceIp, pskSni);
|
|
||||||
return 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`);
|
log(`Session resumption without extractable SNI`);
|
||||||
// If allowSessionTicket=false, should be rejected by caller
|
// If allowSessionTicket=false, should be rejected by caller
|
||||||
}
|
}
|
||||||
@ -1451,16 +1293,7 @@ export class SniHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If we couldn't extract an SNI, check if this is a valid ClientHello
|
// 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)) {
|
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');
|
log('Valid ClientHello detected, but no SNI extracted - might need more data');
|
||||||
}
|
}
|
||||||
|
|
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.iptablesproxy.js';
|
||||||
export * from './classes.networkproxy.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.port80handler.js';
|
||||||
export * from './classes.sslredirect.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