Compare commits
38 Commits
Author | SHA1 | Date | |
---|---|---|---|
fe60f88746 | |||
252a987344 | |||
677d30563f | |||
9aa747b5d4 | |||
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 | |||
6daf4c914d | |||
36e4341315 | |||
474134d29c | |||
43378becd2 |
143
changelog.md
143
changelog.md
@ -1,5 +1,148 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-03-15 - 4.1.1 - fix(tls)
|
||||
Enforce strict SNI handling in TLS connections by terminating ClientHello messages lacking SNI when session tickets are disallowed and removing legacy session cache code.
|
||||
|
||||
- In classes.pp.connectionhandler.ts, if allowSessionTicket is false and no SNI is extracted from a ClientHello, the connection is terminated to force a new handshake with SNI.
|
||||
- In classes.pp.snihandler.ts, removed session cache and related cleanup functions used for tab reactivation, simplifying SNI extraction logic.
|
||||
- Improved logging in TLS processing to aid in diagnosing handshake and session resumption issues.
|
||||
|
||||
## 2025-03-14 - 4.1.0 - feat(SniHandler)
|
||||
Enhance SNI extraction to support session caching and tab reactivation by adding session cache initialization, cleanup and helper methods. Update processTlsPacket to use cached SNI for session resumption and connection racing scenarios.
|
||||
|
||||
- Introduce initSessionCacheCleanup, cleanupSessionCache, createClientKey, cacheSession, and getCachedSession methods to manage SNI information.
|
||||
- Cache SNI based on client IP and client random to improve handling of fragmented ClientHello messages and tab reactivation.
|
||||
- Update processTlsPacket to leverage cached SNI when standard extraction fails, reducing redundant extraction and enhancing connection racing behavior.
|
||||
|
||||
## 2025-03-14 - 4.0.0 - BREAKING CHANGE(core)
|
||||
refactor: reorganize internal module structure to use 'classes.pp.*' modules
|
||||
|
||||
- Renamed port proxy and SNI handler source files to 'classes.pp.portproxy.js' and 'classes.pp.snihandler.js' respectively
|
||||
- Updated import paths in index.ts and test files (e.g. in test.ts and test.router.ts) to reference the new file names
|
||||
- This refactor improves code organization but breaks direct imports from the old paths
|
||||
|
||||
- Renamed 'ts/classes.portproxy.ts' to 'ts/classes.pp.portproxy.ts'
|
||||
- Renamed 'ts/classes.snihandler.ts' to 'ts/classes.pp.snihandler.ts'
|
||||
- Updated exports in index.ts to export from 'classes.pp.portproxy.js' and 'classes.pp.snihandler.js'
|
||||
- Updated test files to import modules from new paths
|
||||
|
||||
## 2025-03-12 - 3.41.8 - fix(portproxy)
|
||||
Improve TLS handshake timeout handling and connection piping in PortProxy
|
||||
|
||||
- Increase the default initial handshake timeout from 60 seconds to 120 seconds
|
||||
- Add a 30-second grace period before terminating connections waiting for initial TLS data
|
||||
- Refactor piping logic by removing redundant callback and establishing piping immediately after flushing buffered data
|
||||
- Enhance debug logging during TLS ClientHello processing for improved SNI extraction insights
|
||||
|
||||
## 2025-03-12 - 3.41.7 - fix(core)
|
||||
Refactor PortProxy and SniHandler: improve configuration handling, logging, and whitespace consistency
|
||||
|
||||
- Standardized indentation and spacing for configuration properties in PortProxy settings (e.g. ACME options, keepAliveProbes, allowSessionTicket)
|
||||
- Simplified conditional formatting and improved inline comments in PortProxy
|
||||
- Enhanced logging messages in SniHandler for TLS handshake and session resumption detection
|
||||
- Improved debugging output (e.g. hexdump of initial TLS packet) and consistency of multi-line expressions
|
||||
|
||||
## 2025-03-12 - 3.41.6 - fix(SniHandler)
|
||||
Refactor SniHandler: update whitespace, comment formatting, and consistent type definitions
|
||||
|
||||
- Unified inline comment style and spacing in SniHandler
|
||||
- Refactored session cache type declaration for clarity
|
||||
- Adjusted buffer length calculations to include TLS record header consistently
|
||||
- Minor improvements to logging messages during ClientHello reassembly and SNI extraction
|
||||
|
||||
## 2025-03-12 - 3.41.5 - fix(portproxy)
|
||||
Enforce TLS handshake and SNI validation on port 443 by blocking non-TLS connections and terminating session resumption attempts without SNI when allowSessionTicket is disabled.
|
||||
|
||||
- Added explicit check to block non-TLS connections on port 443 to ensure proper TLS usage.
|
||||
- Enhanced logging for TLS ClientHello to include details on SNI extraction and session resumption status.
|
||||
- Terminate connections with missing SNI by setting termination reasons ('session_ticket_blocked' or 'no_sni_blocked').
|
||||
- Ensured consistent rejection of non-TLS handshakes on standard HTTPS port.
|
||||
|
||||
## 2025-03-12 - 3.41.4 - fix(tls/sni)
|
||||
Improve logging for TLS session resumption by extracting and logging SNI values from ClientHello messages.
|
||||
|
||||
- Added logging to output the extracted SNI value during renegotiation, initial ClientHello and in the SNI handler.
|
||||
- Enhanced error handling during SNI extraction to aid troubleshooting of TLS session resumption issues.
|
||||
|
||||
## 2025-03-12 - 3.41.3 - fix(TLS/SNI)
|
||||
Improve TLS session resumption handling and logging. Now, session resumption attempts are always logged with details, and connections without a proper SNI are rejected when allowSessionTicket is disabled. In addition, empty SNI extensions are explicitly treated as missing, ensuring stricter and more consistent TLS handshake validation.
|
||||
|
||||
- Always log session resumption in both renegotiation and initial ClientHello processing.
|
||||
- Terminate connections that attempt session resumption without SNI when allowSessionTicket is false.
|
||||
- Treat empty SNI extensions as absence of SNI to improve consistency in TLS handshake processing.
|
||||
|
||||
## 2025-03-11 - 3.41.2 - fix(SniHandler)
|
||||
Refactor hasSessionResumption to return detailed session resumption info
|
||||
|
||||
- Changed the return type of hasSessionResumption from boolean to an object with properties isResumption and hasSNI
|
||||
- Updated early return conditions to return { isResumption: false, hasSNI: false } when buffer is too short or invalid
|
||||
- Modified corresponding documentation to reflect the new return type
|
||||
|
||||
## 2025-03-11 - 3.41.1 - fix(SniHandler)
|
||||
Improve TLS SNI session resumption handling: connections containing a session ticket are now only rejected when no SNI is present and allowSessionTicket is disabled. Updated return values and logging for clearer resumption detection.
|
||||
|
||||
- Changed SniHandler.hasSessionResumption to return an object with 'isResumption' and 'hasSNI' flags.
|
||||
- Adjusted PortProxy logic to only terminate connections when a session ticket is detected without an accompanying SNI (when allowSessionTicket is false).
|
||||
- Enhanced debug logging to clearly differentiate between session resumption scenarios with and without SNI.
|
||||
|
||||
## 2025-03-11 - 3.41.0 - feat(PortProxy/TLS)
|
||||
Add allowSessionTicket option to control TLS session ticket handling
|
||||
|
||||
- Introduce 'allowSessionTicket' flag (default true) in PortProxy settings to enable or disable TLS session resumption via session tickets.
|
||||
- Update SniHandler with a new hasSessionResumption method to detect session ticket and PSK extensions in ClientHello messages.
|
||||
- Force connection cleanup during renegotiation and initial handshake when allowSessionTicket is set to false and a session ticket is detected.
|
||||
|
||||
## 2025-03-11 - 3.40.0 - feat(SniHandler)
|
||||
Add session cache support and tab reactivation detection to improve SNI extraction in TLS handshakes
|
||||
|
||||
- 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)
|
||||
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.
|
||||
|
||||
- Defined TLS_PSK_EXTENSION_TYPE and TLS_PSK_KE_MODES_EXTENSION_TYPE constants.
|
||||
- Added extractSNIFromPSKExtension method to handle ClientHello messages containing PSK identities.
|
||||
- Improved logging to indicate when session resumption indicators (ticket or PSK) are present but no standard SNI is found.
|
||||
- Enhanced extractSNIWithResumptionSupport to attempt PSK extraction if standard SNI extraction fails.
|
||||
|
||||
## 2025-03-11 - 3.37.2 - fix(PortProxy)
|
||||
Improve buffering and data handling during connection setup in PortProxy to prevent data loss
|
||||
|
||||
- Added a safeDataHandler and processDataQueue to buffer incoming data reliably during the TLS handshake phase
|
||||
- Introduced a queue with pause/resume logic to avoid exceeding maxPendingDataSize and ensure all pending data is flushed before piping begins
|
||||
- Refactored the piping setup to install the renegotiation handler only after proper data flushing
|
||||
|
||||
## 2025-03-11 - 3.37.1 - fix(PortProxy/SNI)
|
||||
Refactor SNI extraction in PortProxy to use the dedicated SniHandler class
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "3.37.1",
|
||||
"version": "4.1.1",
|
||||
"private": false,
|
||||
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.",
|
||||
"main": "dist_ts/index.js",
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { expect, tap } from '@push.rocks/tapbundle';
|
||||
import * as net from 'net';
|
||||
import { PortProxy } from '../ts/classes.portproxy.js';
|
||||
import { PortProxy } from '../ts/classes.pp.portproxy.js';
|
||||
|
||||
let testServer: net.Server;
|
||||
let portProxy: PortProxy;
|
||||
@ -299,8 +299,8 @@ tap.test('should use round robin for multiple target IPs in domain config', asyn
|
||||
|
||||
// Don't track this proxy as it doesn't actually start or listen
|
||||
|
||||
const firstTarget = (proxyInstance as any).getTargetIP(domainConfig);
|
||||
const secondTarget = (proxyInstance as any).getTargetIP(domainConfig);
|
||||
const firstTarget = proxyInstance.domainConfigManager.getTargetIP(domainConfig);
|
||||
const secondTarget = proxyInstance.domainConfigManager.getTargetIP(domainConfig);
|
||||
expect(firstTarget).toEqual('hostA');
|
||||
expect(secondTarget).toEqual('hostB');
|
||||
});
|
||||
|
@ -226,8 +226,8 @@ tap.test('should start the proxy server', async () => {
|
||||
// Awaiting the update ensures that the SNI context is added before any requests come in.
|
||||
await testProxy.updateProxyConfigs([
|
||||
{
|
||||
destinationIp: '127.0.0.1',
|
||||
destinationPort: '3000',
|
||||
destinationIps: ['127.0.0.1'],
|
||||
destinationPorts: [3000],
|
||||
hostName: 'push.rocks',
|
||||
publicKey: testCertificates.publicKey,
|
||||
privateKey: testCertificates.privateKey,
|
||||
@ -280,8 +280,8 @@ tap.test('should support WebSocket connections', async () => {
|
||||
// Reconfigure proxy with test certificates if necessary
|
||||
await testProxy.updateProxyConfigs([
|
||||
{
|
||||
destinationIp: '127.0.0.1',
|
||||
destinationPort: '3000',
|
||||
destinationIps: ['127.0.0.1'],
|
||||
destinationPorts: [3000],
|
||||
hostName: 'push.rocks',
|
||||
publicKey: testCertificates.publicKey,
|
||||
privateKey: testCertificates.privateKey,
|
||||
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '3.37.1',
|
||||
version: '4.1.1',
|
||||
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;
|
||||
}
|
||||
}
|
1026
ts/classes.pp.connectionhandler.ts
Normal file
1026
ts/classes.pp.connectionhandler.ts
Normal file
File diff suppressed because it is too large
Load Diff
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();
|
||||
}
|
||||
}
|
1281
ts/classes.pp.snihandler.ts
Normal file
1281
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,331 +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;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// 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 {
|
||||
// Skip this extension
|
||||
pos += extensionLength;
|
||||
}
|
||||
}
|
||||
|
||||
// Log if we found a session ticket but no SNI
|
||||
if (hasSessionTicket) {
|
||||
log('Session ticket present but no SNI found - possible resumption scenario');
|
||||
}
|
||||
|
||||
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 an initial ClientHello packet and handles
|
||||
* session resumption edge cases more robustly than the standard extraction.
|
||||
*
|
||||
* This method is specifically designed for Chrome and other browsers that
|
||||
* may send different ClientHello formats during session 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) {
|
||||
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');
|
||||
}
|
||||
|
||||
// Additional handling could be implemented here for specific browser behaviors
|
||||
// For now, this is a placeholder for future improvements
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
export * from './classes.iptablesproxy.js';
|
||||
export * from './classes.networkproxy.js';
|
||||
export * from './classes.portproxy.js';
|
||||
export * from './classes.pp.portproxy.js';
|
||||
export * from './classes.port80handler.js';
|
||||
export * from './classes.sslredirect.js';
|
||||
export * from './classes.snihandler.js';
|
||||
export * from './classes.pp.snihandler.js';
|
||||
|
Reference in New Issue
Block a user