Compare commits
30 Commits
Author | SHA1 | Date | |
---|---|---|---|
1de9491e1d | |||
e2ee673197 | |||
985031e9ac | |||
4c0105ad09 | |||
06896b3102 | |||
7fe455b4df | |||
21801aa53d | |||
ddfbcdb1f3 | |||
b401d126bc | |||
baaee0ad4d | |||
fe7c4c2f5e | |||
ab1ec84832 | |||
156abbf5b4 | |||
1a90566622 | |||
b48b90d613 | |||
124f8d48b7 | |||
b2a57ada5d | |||
62a3e1f4b7 | |||
3a1485213a | |||
9dbf6fdeb5 | |||
9496dd5336 | |||
29d28fba93 | |||
8196de4fa3 | |||
6fddafe9fd | |||
1e89062167 | |||
21a24fd95b | |||
03ef5e7f6e | |||
415b82a84a | |||
f304cc67b4 | |||
0e12706176 |
114
changelog.md
114
changelog.md
@ -1,5 +1,119 @@
|
|||||||
# 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)
|
||||||
|
Improve TLS handshake timeout handling and connection piping in PortProxy
|
||||||
|
|
||||||
|
- Increase the default initial handshake timeout from 60 seconds to 120 seconds
|
||||||
|
- Add a 30-second grace period before terminating connections waiting for initial TLS data
|
||||||
|
- Refactor piping logic by removing redundant callback and establishing piping immediately after flushing buffered data
|
||||||
|
- Enhance debug logging during TLS ClientHello processing for improved SNI extraction insights
|
||||||
|
|
||||||
|
## 2025-03-12 - 3.41.7 - fix(core)
|
||||||
|
Refactor PortProxy and SniHandler: improve configuration handling, logging, and whitespace consistency
|
||||||
|
|
||||||
|
- Standardized indentation and spacing for configuration properties in PortProxy settings (e.g. ACME options, keepAliveProbes, allowSessionTicket)
|
||||||
|
- Simplified conditional formatting and improved inline comments in PortProxy
|
||||||
|
- Enhanced logging messages in SniHandler for TLS handshake and session resumption detection
|
||||||
|
- Improved debugging output (e.g. hexdump of initial TLS packet) and consistency of multi-line expressions
|
||||||
|
|
||||||
|
## 2025-03-12 - 3.41.6 - fix(SniHandler)
|
||||||
|
Refactor SniHandler: update whitespace, comment formatting, and consistent type definitions
|
||||||
|
|
||||||
|
- Unified inline comment style and spacing in SniHandler
|
||||||
|
- Refactored session cache type declaration for clarity
|
||||||
|
- Adjusted buffer length calculations to include TLS record header consistently
|
||||||
|
- Minor improvements to logging messages during ClientHello reassembly and SNI extraction
|
||||||
|
|
||||||
|
## 2025-03-12 - 3.41.5 - fix(portproxy)
|
||||||
|
Enforce TLS handshake and SNI validation on port 443 by blocking non-TLS connections and terminating session resumption attempts without SNI when allowSessionTicket is disabled.
|
||||||
|
|
||||||
|
- Added explicit check to block non-TLS connections on port 443 to ensure proper TLS usage.
|
||||||
|
- Enhanced logging for TLS ClientHello to include details on SNI extraction and session resumption status.
|
||||||
|
- Terminate connections with missing SNI by setting termination reasons ('session_ticket_blocked' or 'no_sni_blocked').
|
||||||
|
- Ensured consistent rejection of non-TLS handshakes on standard HTTPS port.
|
||||||
|
|
||||||
|
## 2025-03-12 - 3.41.4 - fix(tls/sni)
|
||||||
|
Improve logging for TLS session resumption by extracting and logging SNI values from ClientHello messages.
|
||||||
|
|
||||||
|
- Added logging to output the extracted SNI value during renegotiation, initial ClientHello and in the SNI handler.
|
||||||
|
- Enhanced error handling during SNI extraction to aid troubleshooting of TLS session resumption issues.
|
||||||
|
|
||||||
|
## 2025-03-12 - 3.41.3 - fix(TLS/SNI)
|
||||||
|
Improve TLS session resumption handling and logging. Now, session resumption attempts are always logged with details, and connections without a proper SNI are rejected when allowSessionTicket is disabled. In addition, empty SNI extensions are explicitly treated as missing, ensuring stricter and more consistent TLS handshake validation.
|
||||||
|
|
||||||
|
- Always log session resumption in both renegotiation and initial ClientHello processing.
|
||||||
|
- Terminate connections that attempt session resumption without SNI when allowSessionTicket is false.
|
||||||
|
- Treat empty SNI extensions as absence of SNI to improve consistency in TLS handshake processing.
|
||||||
|
|
||||||
|
## 2025-03-11 - 3.41.2 - fix(SniHandler)
|
||||||
|
Refactor hasSessionResumption to return detailed session resumption info
|
||||||
|
|
||||||
|
- Changed the return type of hasSessionResumption from boolean to an object with properties isResumption and hasSNI
|
||||||
|
- Updated early return conditions to return { isResumption: false, hasSNI: false } when buffer is too short or invalid
|
||||||
|
- Modified corresponding documentation to reflect the new return type
|
||||||
|
|
||||||
|
## 2025-03-11 - 3.41.1 - fix(SniHandler)
|
||||||
|
Improve TLS SNI session resumption handling: connections containing a session ticket are now only rejected when no SNI is present and allowSessionTicket is disabled. Updated return values and logging for clearer resumption detection.
|
||||||
|
|
||||||
|
- Changed SniHandler.hasSessionResumption to return an object with 'isResumption' and 'hasSNI' flags.
|
||||||
|
- Adjusted PortProxy logic to only terminate connections when a session ticket is detected without an accompanying SNI (when allowSessionTicket is false).
|
||||||
|
- Enhanced debug logging to clearly differentiate between session resumption scenarios with and without SNI.
|
||||||
|
|
||||||
|
## 2025-03-11 - 3.41.0 - feat(PortProxy/TLS)
|
||||||
|
Add allowSessionTicket option to control TLS session ticket handling
|
||||||
|
|
||||||
|
- Introduce 'allowSessionTicket' flag (default true) in PortProxy settings to enable or disable TLS session resumption via session tickets.
|
||||||
|
- Update SniHandler with a new hasSessionResumption method to detect session ticket and PSK extensions in ClientHello messages.
|
||||||
|
- Force connection cleanup during renegotiation and initial handshake when allowSessionTicket is set to false and a session ticket is detected.
|
||||||
|
|
||||||
|
## 2025-03-11 - 3.40.0 - feat(SniHandler)
|
||||||
|
Add session cache support and tab reactivation detection to improve SNI extraction in TLS handshakes
|
||||||
|
|
||||||
|
- Introduce a session cache mechanism to store and retrieve cached SNI values based on client IP (and optionally client random) to better handle tab reactivation scenarios.
|
||||||
|
- Implement functions to initialize, update, and clean up the session cache for TLS ClientHello messages.
|
||||||
|
- Enhance SNI extraction logic to check for tab reactivation handshakes and to return cached SNI for resumed connections or 0-RTT scenarios.
|
||||||
|
- Update PSK extension handling to safely skip over obfuscated ticket age bytes.
|
||||||
|
|
||||||
|
## 2025-03-11 - 3.39.0 - feat(PortProxy)
|
||||||
|
Add domain-specific NetworkProxy integration support to PortProxy
|
||||||
|
|
||||||
|
- Introduced new properties 'useNetworkProxy' and 'networkProxyPort' in domain configurations.
|
||||||
|
- Updated forwardToNetworkProxy to accept an optional custom proxy port parameter.
|
||||||
|
- Enhanced TLS handshake processing to extract SNI and, if a matching domain config specifies NetworkProxy usage, forward the connection using the domain-specific port.
|
||||||
|
- Refined connection routing logic to check for domain-specific NetworkProxy settings before falling back to default behavior.
|
||||||
|
|
||||||
|
## 2025-03-11 - 3.38.2 - fix(core)
|
||||||
|
No code changes detected; bumping patch version for consistency.
|
||||||
|
|
||||||
|
|
||||||
|
## 2025-03-11 - 3.38.1 - fix(PortProxy)
|
||||||
|
Improve SNI extraction handling in PortProxy by passing explicit connection info to extractSNIWithResumptionSupport for better TLS renegotiation and debug logging.
|
||||||
|
|
||||||
|
- In the renegotiation handler, create and pass a connection info object (sourceIp, sourcePort, destIp, destPort) instead of a boolean flag.
|
||||||
|
- Update the TLS handshake processing to construct a connection info object for detailed SNI extraction and logging.
|
||||||
|
- Enhance consistency by using processTlsPacket with cached SNI hints during fallback.
|
||||||
|
|
||||||
|
## 2025-03-11 - 3.38.0 - feat(SniHandler)
|
||||||
|
Enhance SNI extraction to support fragmented ClientHello messages, TLS 1.3 early data, and improved PSK parsing
|
||||||
|
|
||||||
|
- Added isTlsApplicationData method for detecting TLS application data packets
|
||||||
|
- Implemented handleFragmentedClientHello to buffer and reassemble fragmented ClientHello messages
|
||||||
|
- Extended extractSNIWithResumptionSupport to accept connection information and use reassembled data
|
||||||
|
- Added detection for TLS 1.3 early data (0-RTT) in the ClientHello, supporting session resumption scenarios
|
||||||
|
- Improved logging and heuristics for handling potential connection racing in modern browsers
|
||||||
|
|
||||||
## 2025-03-11 - 3.37.3 - fix(snihandler)
|
## 2025-03-11 - 3.37.3 - fix(snihandler)
|
||||||
Enhance SNI extraction to support TLS 1.3 PSK-based session resumption by adding a dedicated extractSNIFromPSKExtension method and improved logging for session resumption indicators.
|
Enhance SNI extraction to support TLS 1.3 PSK-based session resumption by adding a dedicated extractSNIFromPSKExtension method and improved logging for session resumption indicators.
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartproxy",
|
"name": "@push.rocks/smartproxy",
|
||||||
"version": "3.37.3",
|
"version": "4.0.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.",
|
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
|
@ -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.37.3',
|
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();
|
||||||
|
}
|
||||||
|
}
|
1302
ts/classes.pp.snihandler.ts
Normal file
1302
ts/classes.pp.snihandler.ts
Normal file
File diff suppressed because it is too large
Load Diff
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,519 +0,0 @@
|
|||||||
import { Buffer } from 'buffer';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SNI (Server Name Indication) handler for TLS connections.
|
|
||||||
* Provides robust extraction of SNI values from TLS ClientHello messages.
|
|
||||||
*/
|
|
||||||
export class SniHandler {
|
|
||||||
// TLS record types and constants
|
|
||||||
private static readonly TLS_HANDSHAKE_RECORD_TYPE = 22;
|
|
||||||
private static readonly TLS_CLIENT_HELLO_HANDSHAKE_TYPE = 1;
|
|
||||||
private static readonly TLS_SNI_EXTENSION_TYPE = 0x0000;
|
|
||||||
private static readonly TLS_SESSION_TICKET_EXTENSION_TYPE = 0x0023;
|
|
||||||
private static readonly TLS_SNI_HOST_NAME_TYPE = 0;
|
|
||||||
private static readonly TLS_PSK_EXTENSION_TYPE = 0x0029; // Pre-Shared Key extension type for TLS 1.3
|
|
||||||
private static readonly TLS_PSK_KE_MODES_EXTENSION_TYPE = 0x002D; // PSK Key Exchange Modes
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a buffer contains a TLS handshake message (record type 22)
|
|
||||||
* @param buffer - The buffer to check
|
|
||||||
* @returns true if the buffer starts with a TLS handshake record type
|
|
||||||
*/
|
|
||||||
public static isTlsHandshake(buffer: Buffer): boolean {
|
|
||||||
return buffer.length > 0 && buffer[0] === this.TLS_HANDSHAKE_RECORD_TYPE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a buffer contains a TLS ClientHello message
|
|
||||||
* @param buffer - The buffer to check
|
|
||||||
* @returns true if the buffer appears to be a ClientHello message
|
|
||||||
*/
|
|
||||||
public static isClientHello(buffer: Buffer): boolean {
|
|
||||||
// Minimum ClientHello size (TLS record header + handshake header)
|
|
||||||
if (buffer.length < 9) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check record type (must be TLS_HANDSHAKE_RECORD_TYPE)
|
|
||||||
if (buffer[0] !== this.TLS_HANDSHAKE_RECORD_TYPE) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip version and length in TLS record header (5 bytes total)
|
|
||||||
// Check handshake type at byte 5 (must be CLIENT_HELLO)
|
|
||||||
return buffer[5] === this.TLS_CLIENT_HELLO_HANDSHAKE_TYPE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts the SNI (Server Name Indication) from a TLS ClientHello message.
|
|
||||||
* Implements robust parsing with support for session resumption edge cases.
|
|
||||||
*
|
|
||||||
* @param buffer - The buffer containing the TLS ClientHello message
|
|
||||||
* @param enableLogging - Whether to enable detailed debug logging
|
|
||||||
* @returns The extracted server name or undefined if not found
|
|
||||||
*/
|
|
||||||
public static extractSNI(buffer: Buffer, enableLogging: boolean = false): string | undefined {
|
|
||||||
// Logging helper
|
|
||||||
const log = (message: string) => {
|
|
||||||
if (enableLogging) {
|
|
||||||
console.log(`[SNI Extraction] ${message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Buffer must be at least 5 bytes (TLS record header)
|
|
||||||
if (buffer.length < 5) {
|
|
||||||
log('Buffer too small for TLS record header');
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check record type (must be TLS_HANDSHAKE_RECORD_TYPE = 22)
|
|
||||||
if (buffer[0] !== this.TLS_HANDSHAKE_RECORD_TYPE) {
|
|
||||||
log(`Not a TLS handshake record: ${buffer[0]}`);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check TLS version
|
|
||||||
const majorVersion = buffer[1];
|
|
||||||
const minorVersion = buffer[2];
|
|
||||||
log(`TLS version: ${majorVersion}.${minorVersion}`);
|
|
||||||
|
|
||||||
// Parse record length (bytes 3-4, big-endian)
|
|
||||||
const recordLength = (buffer[3] << 8) + buffer[4];
|
|
||||||
log(`Record length: ${recordLength}`);
|
|
||||||
|
|
||||||
// Validate record length against buffer size
|
|
||||||
if (buffer.length < recordLength + 5) {
|
|
||||||
log('Buffer smaller than expected record length');
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start of handshake message in the buffer
|
|
||||||
let pos = 5;
|
|
||||||
|
|
||||||
// Check handshake type (must be CLIENT_HELLO = 1)
|
|
||||||
if (buffer[pos] !== this.TLS_CLIENT_HELLO_HANDSHAKE_TYPE) {
|
|
||||||
log(`Not a ClientHello message: ${buffer[pos]}`);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip handshake type (1 byte)
|
|
||||||
pos += 1;
|
|
||||||
|
|
||||||
// Parse handshake length (3 bytes, big-endian)
|
|
||||||
const handshakeLength = (buffer[pos] << 16) + (buffer[pos + 1] << 8) + buffer[pos + 2];
|
|
||||||
log(`Handshake length: ${handshakeLength}`);
|
|
||||||
|
|
||||||
// Skip handshake length (3 bytes)
|
|
||||||
pos += 3;
|
|
||||||
|
|
||||||
// Check client version (2 bytes)
|
|
||||||
const clientMajorVersion = buffer[pos];
|
|
||||||
const clientMinorVersion = buffer[pos + 1];
|
|
||||||
log(`Client version: ${clientMajorVersion}.${clientMinorVersion}`);
|
|
||||||
|
|
||||||
// Skip client version (2 bytes)
|
|
||||||
pos += 2;
|
|
||||||
|
|
||||||
// Skip client random (32 bytes)
|
|
||||||
pos += 32;
|
|
||||||
|
|
||||||
// Parse session ID
|
|
||||||
if (pos + 1 > buffer.length) {
|
|
||||||
log('Buffer too small for session ID length');
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionIdLength = buffer[pos];
|
|
||||||
log(`Session ID length: ${sessionIdLength}`);
|
|
||||||
|
|
||||||
// Skip session ID length (1 byte) and session ID
|
|
||||||
pos += 1 + sessionIdLength;
|
|
||||||
|
|
||||||
// Check if we have enough bytes left
|
|
||||||
if (pos + 2 > buffer.length) {
|
|
||||||
log('Buffer too small for cipher suites length');
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse cipher suites length (2 bytes, big-endian)
|
|
||||||
const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1];
|
|
||||||
log(`Cipher suites length: ${cipherSuitesLength}`);
|
|
||||||
|
|
||||||
// Skip cipher suites length (2 bytes) and cipher suites
|
|
||||||
pos += 2 + cipherSuitesLength;
|
|
||||||
|
|
||||||
// Check if we have enough bytes left
|
|
||||||
if (pos + 1 > buffer.length) {
|
|
||||||
log('Buffer too small for compression methods length');
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse compression methods length (1 byte)
|
|
||||||
const compressionMethodsLength = buffer[pos];
|
|
||||||
log(`Compression methods length: ${compressionMethodsLength}`);
|
|
||||||
|
|
||||||
// Skip compression methods length (1 byte) and compression methods
|
|
||||||
pos += 1 + compressionMethodsLength;
|
|
||||||
|
|
||||||
// Check if we have enough bytes for extensions length
|
|
||||||
if (pos + 2 > buffer.length) {
|
|
||||||
log('No extensions present or buffer too small');
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse extensions length (2 bytes, big-endian)
|
|
||||||
const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1];
|
|
||||||
log(`Extensions length: ${extensionsLength}`);
|
|
||||||
|
|
||||||
// Skip extensions length (2 bytes)
|
|
||||||
pos += 2;
|
|
||||||
|
|
||||||
// Extensions end position
|
|
||||||
const extensionsEnd = pos + extensionsLength;
|
|
||||||
|
|
||||||
// Check if extensions length is valid
|
|
||||||
if (extensionsEnd > buffer.length) {
|
|
||||||
log('Extensions length exceeds buffer size');
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track if we found session tickets (for improved resumption handling)
|
|
||||||
let hasSessionTicket = false;
|
|
||||||
let hasPskExtension = false;
|
|
||||||
|
|
||||||
// Iterate through extensions
|
|
||||||
while (pos + 4 <= extensionsEnd) {
|
|
||||||
// Parse extension type (2 bytes, big-endian)
|
|
||||||
const extensionType = (buffer[pos] << 8) + buffer[pos + 1];
|
|
||||||
log(`Extension type: 0x${extensionType.toString(16).padStart(4, '0')}`);
|
|
||||||
|
|
||||||
// Skip extension type (2 bytes)
|
|
||||||
pos += 2;
|
|
||||||
|
|
||||||
// Parse extension length (2 bytes, big-endian)
|
|
||||||
const extensionLength = (buffer[pos] << 8) + buffer[pos + 1];
|
|
||||||
log(`Extension length: ${extensionLength}`);
|
|
||||||
|
|
||||||
// Skip extension length (2 bytes)
|
|
||||||
pos += 2;
|
|
||||||
|
|
||||||
// Check if this is the SNI extension
|
|
||||||
if (extensionType === this.TLS_SNI_EXTENSION_TYPE) {
|
|
||||||
log('Found SNI extension');
|
|
||||||
|
|
||||||
// Ensure we have enough bytes for the server name list
|
|
||||||
if (pos + 2 > extensionsEnd) {
|
|
||||||
log('Extension too small for server name list length');
|
|
||||||
pos += extensionLength; // Skip this extension
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse server name list length (2 bytes, big-endian)
|
|
||||||
const serverNameListLength = (buffer[pos] << 8) + buffer[pos + 1];
|
|
||||||
log(`Server name list length: ${serverNameListLength}`);
|
|
||||||
|
|
||||||
// Skip server name list length (2 bytes)
|
|
||||||
pos += 2;
|
|
||||||
|
|
||||||
// Ensure server name list length is valid
|
|
||||||
if (pos + serverNameListLength > extensionsEnd) {
|
|
||||||
log('Server name list length exceeds extension size');
|
|
||||||
break; // Exit the loop, extension parsing is broken
|
|
||||||
}
|
|
||||||
|
|
||||||
// End position of server name list
|
|
||||||
const serverNameListEnd = pos + serverNameListLength;
|
|
||||||
|
|
||||||
// Iterate through server names
|
|
||||||
while (pos + 3 <= serverNameListEnd) {
|
|
||||||
// Check name type (must be HOST_NAME_TYPE = 0 for hostname)
|
|
||||||
const nameType = buffer[pos];
|
|
||||||
log(`Name type: ${nameType}`);
|
|
||||||
|
|
||||||
if (nameType !== this.TLS_SNI_HOST_NAME_TYPE) {
|
|
||||||
log(`Unsupported name type: ${nameType}`);
|
|
||||||
pos += 1; // Skip name type (1 byte)
|
|
||||||
|
|
||||||
// Skip name length (2 bytes) and name data
|
|
||||||
if (pos + 2 <= serverNameListEnd) {
|
|
||||||
const nameLength = (buffer[pos] << 8) + buffer[pos + 1];
|
|
||||||
pos += 2 + nameLength;
|
|
||||||
} else {
|
|
||||||
log('Invalid server name entry');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip name type (1 byte)
|
|
||||||
pos += 1;
|
|
||||||
|
|
||||||
// Ensure we have enough bytes for name length
|
|
||||||
if (pos + 2 > serverNameListEnd) {
|
|
||||||
log('Server name entry too small for name length');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse name length (2 bytes, big-endian)
|
|
||||||
const nameLength = (buffer[pos] << 8) + buffer[pos + 1];
|
|
||||||
log(`Name length: ${nameLength}`);
|
|
||||||
|
|
||||||
// Skip name length (2 bytes)
|
|
||||||
pos += 2;
|
|
||||||
|
|
||||||
// Ensure we have enough bytes for the name
|
|
||||||
if (pos + nameLength > serverNameListEnd) {
|
|
||||||
log('Name length exceeds server name list size');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract server name (hostname)
|
|
||||||
const serverName = buffer.slice(pos, pos + nameLength).toString('utf8');
|
|
||||||
log(`Extracted server name: ${serverName}`);
|
|
||||||
return serverName;
|
|
||||||
}
|
|
||||||
} else if (extensionType === this.TLS_SESSION_TICKET_EXTENSION_TYPE) {
|
|
||||||
// If we encounter a session ticket extension, mark it for later
|
|
||||||
log('Found session ticket extension');
|
|
||||||
hasSessionTicket = true;
|
|
||||||
pos += extensionLength; // Skip this extension
|
|
||||||
} else if (extensionType === this.TLS_PSK_EXTENSION_TYPE) {
|
|
||||||
// TLS 1.3 PSK extension - mark for resumption support
|
|
||||||
log('Found PSK extension (TLS 1.3 resumption indicator)');
|
|
||||||
hasPskExtension = true;
|
|
||||||
// We'll skip the extension here and process it separately if needed
|
|
||||||
pos += extensionLength;
|
|
||||||
} else {
|
|
||||||
// Skip this extension
|
|
||||||
pos += extensionLength;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log if we found session resumption indicators but no SNI
|
|
||||||
if (hasSessionTicket || hasPskExtension) {
|
|
||||||
log('Session resumption indicators present but no SNI found');
|
|
||||||
}
|
|
||||||
|
|
||||||
log('No SNI extension found in ClientHello');
|
|
||||||
return undefined;
|
|
||||||
} catch (error) {
|
|
||||||
log(`Error parsing SNI: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempts to extract SNI from the PSK extension in a TLS 1.3 ClientHello.
|
|
||||||
*
|
|
||||||
* In TLS 1.3, when a client attempts to resume a session, it may include
|
|
||||||
* the server name in the PSK identity hint rather than in the SNI extension.
|
|
||||||
*
|
|
||||||
* @param buffer - The buffer containing the TLS ClientHello message
|
|
||||||
* @param enableLogging - Whether to enable detailed debug logging
|
|
||||||
* @returns The extracted server name or undefined if not found
|
|
||||||
*/
|
|
||||||
public static extractSNIFromPSKExtension(
|
|
||||||
buffer: Buffer,
|
|
||||||
enableLogging: boolean = false
|
|
||||||
): string | undefined {
|
|
||||||
const log = (message: string) => {
|
|
||||||
if (enableLogging) {
|
|
||||||
console.log(`[PSK-SNI Extraction] ${message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Ensure this is a ClientHello
|
|
||||||
if (!this.isClientHello(buffer)) {
|
|
||||||
log('Not a ClientHello message');
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the start position of extensions
|
|
||||||
let pos = 5; // Start after record header
|
|
||||||
|
|
||||||
// Skip handshake type (1 byte)
|
|
||||||
pos += 1;
|
|
||||||
|
|
||||||
// Skip handshake length (3 bytes)
|
|
||||||
pos += 3;
|
|
||||||
|
|
||||||
// Skip client version (2 bytes)
|
|
||||||
pos += 2;
|
|
||||||
|
|
||||||
// Skip client random (32 bytes)
|
|
||||||
pos += 32;
|
|
||||||
|
|
||||||
// Skip session ID
|
|
||||||
if (pos + 1 > buffer.length) return undefined;
|
|
||||||
const sessionIdLength = buffer[pos];
|
|
||||||
pos += 1 + sessionIdLength;
|
|
||||||
|
|
||||||
// Skip cipher suites
|
|
||||||
if (pos + 2 > buffer.length) return undefined;
|
|
||||||
const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1];
|
|
||||||
pos += 2 + cipherSuitesLength;
|
|
||||||
|
|
||||||
// Skip compression methods
|
|
||||||
if (pos + 1 > buffer.length) return undefined;
|
|
||||||
const compressionMethodsLength = buffer[pos];
|
|
||||||
pos += 1 + compressionMethodsLength;
|
|
||||||
|
|
||||||
// Check if we have extensions
|
|
||||||
if (pos + 2 > buffer.length) {
|
|
||||||
log('No extensions present');
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get extensions length
|
|
||||||
const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1];
|
|
||||||
pos += 2;
|
|
||||||
|
|
||||||
// Extensions end position
|
|
||||||
const extensionsEnd = pos + extensionsLength;
|
|
||||||
if (extensionsEnd > buffer.length) return undefined;
|
|
||||||
|
|
||||||
// Look for PSK extension
|
|
||||||
while (pos + 4 <= extensionsEnd) {
|
|
||||||
const extensionType = (buffer[pos] << 8) + buffer[pos + 1];
|
|
||||||
pos += 2;
|
|
||||||
|
|
||||||
const extensionLength = (buffer[pos] << 8) + buffer[pos + 1];
|
|
||||||
pos += 2;
|
|
||||||
|
|
||||||
if (extensionType === this.TLS_PSK_EXTENSION_TYPE) {
|
|
||||||
log('Found PSK extension');
|
|
||||||
|
|
||||||
// PSK extension structure:
|
|
||||||
// 2 bytes: identities list length
|
|
||||||
if (pos + 2 > extensionsEnd) break;
|
|
||||||
const identitiesLength = (buffer[pos] << 8) + buffer[pos + 1];
|
|
||||||
pos += 2;
|
|
||||||
|
|
||||||
// End of identities list
|
|
||||||
const identitiesEnd = pos + identitiesLength;
|
|
||||||
if (identitiesEnd > extensionsEnd) break;
|
|
||||||
|
|
||||||
// Process each PSK identity
|
|
||||||
while (pos + 2 <= identitiesEnd) {
|
|
||||||
// Identity length (2 bytes)
|
|
||||||
if (pos + 2 > identitiesEnd) break;
|
|
||||||
const identityLength = (buffer[pos] << 8) + buffer[pos + 1];
|
|
||||||
pos += 2;
|
|
||||||
|
|
||||||
if (pos + identityLength > identitiesEnd) break;
|
|
||||||
|
|
||||||
// Try to extract hostname from identity
|
|
||||||
// Chrome often embeds the hostname in the PSK identity
|
|
||||||
// This is a heuristic as there's no standard format
|
|
||||||
if (identityLength > 0) {
|
|
||||||
const identity = buffer.slice(pos, pos + identityLength);
|
|
||||||
|
|
||||||
// Skip identity bytes
|
|
||||||
pos += identityLength;
|
|
||||||
|
|
||||||
// Skip obfuscated ticket age (4 bytes)
|
|
||||||
pos += 4;
|
|
||||||
|
|
||||||
// Try to parse the identity as UTF-8
|
|
||||||
try {
|
|
||||||
const identityStr = identity.toString('utf8');
|
|
||||||
log(`PSK identity: ${identityStr}`);
|
|
||||||
|
|
||||||
// Check if the identity contains hostname hints
|
|
||||||
// Chrome often embeds the hostname in a known format
|
|
||||||
// Try to extract using common patterns
|
|
||||||
|
|
||||||
// Pattern 1: Look for domain name pattern
|
|
||||||
const domainPattern = /([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?/i;
|
|
||||||
const domainMatch = identityStr.match(domainPattern);
|
|
||||||
if (domainMatch && domainMatch[0]) {
|
|
||||||
log(`Found domain in PSK identity: ${domainMatch[0]}`);
|
|
||||||
return domainMatch[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pattern 2: Chrome sometimes uses a specific format with delimiters
|
|
||||||
// This is a heuristic approach since the format isn't standardized
|
|
||||||
const parts = identityStr.split('|');
|
|
||||||
if (parts.length > 1) {
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part.includes('.') && !part.includes('/')) {
|
|
||||||
const possibleDomain = part.trim();
|
|
||||||
if (/^[a-z0-9.-]+$/i.test(possibleDomain)) {
|
|
||||||
log(`Found possible domain in PSK delimiter format: ${possibleDomain}`);
|
|
||||||
return possibleDomain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log('Failed to parse PSK identity as UTF-8');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Skip this extension
|
|
||||||
pos += extensionLength;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log('No hostname found in PSK extension');
|
|
||||||
return undefined;
|
|
||||||
} catch (error) {
|
|
||||||
log(`Error parsing PSK: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempts to extract SNI from an initial ClientHello packet and handles
|
|
||||||
* session resumption edge cases more robustly than the standard extraction.
|
|
||||||
*
|
|
||||||
* This method handles:
|
|
||||||
* 1. Standard SNI extraction
|
|
||||||
* 2. TLS 1.3 PSK-based resumption (Chrome, Firefox, etc.)
|
|
||||||
* 3. Session ticket-based resumption
|
|
||||||
*
|
|
||||||
* @param buffer - The buffer containing the TLS ClientHello message
|
|
||||||
* @param enableLogging - Whether to enable detailed debug logging
|
|
||||||
* @returns The extracted server name or undefined if not found
|
|
||||||
*/
|
|
||||||
public static extractSNIWithResumptionSupport(
|
|
||||||
buffer: Buffer,
|
|
||||||
enableLogging: boolean = false
|
|
||||||
): string | undefined {
|
|
||||||
// First try the standard SNI extraction
|
|
||||||
const standardSni = this.extractSNI(buffer, enableLogging);
|
|
||||||
if (standardSni) {
|
|
||||||
if (enableLogging) {
|
|
||||||
console.log(`[SNI Extraction] Found standard SNI: ${standardSni}`);
|
|
||||||
}
|
|
||||||
return standardSni;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If standard extraction failed and we have a valid ClientHello,
|
|
||||||
// this might be a session resumption with non-standard format
|
|
||||||
if (this.isClientHello(buffer)) {
|
|
||||||
if (enableLogging) {
|
|
||||||
console.log('[SNI Extraction] Detected ClientHello without standard SNI, possible session resumption');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to extract from PSK extension (TLS 1.3 resumption)
|
|
||||||
const pskSni = this.extractSNIFromPSKExtension(buffer, enableLogging);
|
|
||||||
if (pskSni) {
|
|
||||||
if (enableLogging) {
|
|
||||||
console.log(`[SNI Extraction] Extracted SNI from PSK extension: ${pskSni}`);
|
|
||||||
}
|
|
||||||
return pskSni;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Could add more browser-specific heuristics here if needed
|
|
||||||
if (enableLogging) {
|
|
||||||
console.log('[SNI Extraction] Failed to extract SNI from resumption mechanisms');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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';
|
||||||
|
Reference in New Issue
Block a user