598 lines
20 KiB
TypeScript
598 lines
20 KiB
TypeScript
import * as plugins from '../plugins.js';
|
|
|
|
import { ConnectionManager } from './classes.pp.connectionmanager.js';
|
|
import { SecurityManager } from './classes.pp.securitymanager.js';
|
|
import { DomainConfigManager } from './classes.pp.domainconfigmanager.js';
|
|
import { TlsManager } from './classes.pp.tlsmanager.js';
|
|
import { NetworkProxyBridge } from './classes.pp.networkproxybridge.js';
|
|
import { TimeoutManager } from './classes.pp.timeoutmanager.js';
|
|
import { PortRangeManager } from './classes.pp.portrangemanager.js';
|
|
import { ConnectionHandler } from './classes.pp.connectionhandler.js';
|
|
import { Port80Handler } from '../port80handler/classes.port80handler.js';
|
|
import { CertProvisioner } from './classes.pp.certprovisioner.js';
|
|
import type { ICertificateData } from '../common/types.js';
|
|
import { buildPort80Handler } from '../common/acmeFactory.js';
|
|
|
|
import type { ISmartProxyOptions, IDomainConfig } from './classes.pp.interfaces.js';
|
|
export type { ISmartProxyOptions as IPortProxySettings, IDomainConfig };
|
|
|
|
/**
|
|
* SmartProxy - Main class that coordinates all components
|
|
*/
|
|
export class SmartProxy extends plugins.EventEmitter {
|
|
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 portRangeManager: PortRangeManager;
|
|
private connectionHandler: ConnectionHandler;
|
|
|
|
// Port80Handler for ACME certificate management
|
|
private port80Handler: Port80Handler | null = null;
|
|
// CertProvisioner for unified certificate workflows
|
|
private certProvisioner?: CertProvisioner;
|
|
|
|
constructor(settingsArg: ISmartProxyOptions) {
|
|
super();
|
|
// 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 || {},
|
|
globalPortRanges: settingsArg.globalPortRanges || [],
|
|
};
|
|
|
|
// Set default ACME options if not provided
|
|
if (!this.settings.acme || Object.keys(this.settings.acme).length === 0) {
|
|
this.settings.acme = {
|
|
enabled: false,
|
|
port: 80,
|
|
accountEmail: 'admin@example.com',
|
|
useProduction: false,
|
|
renewThresholdDays: 30,
|
|
autoRenew: true,
|
|
certificateStore: './certs',
|
|
skipConfiguredCerts: false,
|
|
httpsRedirectPort: this.settings.fromPort,
|
|
renewCheckIntervalHours: 24,
|
|
domainForwards: []
|
|
};
|
|
}
|
|
|
|
// 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);
|
|
|
|
// 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: ISmartProxyOptions;
|
|
|
|
/**
|
|
* Initialize the Port80Handler for ACME certificate management
|
|
*/
|
|
private async initializePort80Handler(): Promise<void> {
|
|
const config = this.settings.acme!;
|
|
if (!config.enabled) {
|
|
console.log('ACME is disabled in configuration');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Build and start the Port80Handler
|
|
this.port80Handler = buildPort80Handler({
|
|
...config,
|
|
httpsRedirectPort: config.httpsRedirectPort || this.settings.fromPort
|
|
});
|
|
// Share Port80Handler with NetworkProxyBridge before start
|
|
this.networkProxyBridge.setPort80Handler(this.port80Handler);
|
|
await this.port80Handler.start();
|
|
console.log(`Port80Handler started on port ${config.port}`);
|
|
} catch (err) {
|
|
console.log(`Error initializing Port80Handler: ${err}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 Port80Handler if enabled
|
|
await this.initializePort80Handler();
|
|
// Initialize CertProvisioner for unified certificate workflows
|
|
if (this.port80Handler) {
|
|
const acme = this.settings.acme!;
|
|
this.certProvisioner = new CertProvisioner(
|
|
this.settings.domainConfigs,
|
|
this.port80Handler,
|
|
this.networkProxyBridge,
|
|
this.settings.certProvisionFunction,
|
|
acme.renewThresholdDays!,
|
|
acme.renewCheckIntervalHours!,
|
|
acme.autoRenew!,
|
|
acme.domainForwards?.map(f => ({
|
|
domain: f.domain,
|
|
forwardConfig: f.forwardConfig,
|
|
acmeForwardConfig: f.acmeForwardConfig,
|
|
sslRedirect: f.sslRedirect || false
|
|
})) || []
|
|
);
|
|
this.certProvisioner.on('certificate', (certData) => {
|
|
this.emit('certificate', {
|
|
domain: certData.domain,
|
|
publicKey: certData.certificate,
|
|
privateKey: certData.privateKey,
|
|
expiryDate: certData.expiryDate,
|
|
source: certData.source,
|
|
isRenewal: certData.isRenewal
|
|
});
|
|
});
|
|
await this.certProvisioner.start();
|
|
console.log('CertProvisioner started');
|
|
}
|
|
|
|
// 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 CertProvisioner if active
|
|
if (this.certProvisioner) {
|
|
await this.certProvisioner.stop();
|
|
console.log('CertProvisioner stopped');
|
|
}
|
|
|
|
// Stop the Port80Handler if running
|
|
if (this.port80Handler) {
|
|
try {
|
|
await this.port80Handler.stop();
|
|
console.log('Port80Handler stopped');
|
|
this.port80Handler = null;
|
|
} catch (err) {
|
|
console.log(`Error stopping Port80Handler: ${err}`);
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
// If Port80Handler is running, provision certificates per new domain
|
|
if (this.port80Handler && this.settings.acme?.enabled) {
|
|
for (const domainConfig of newDomainConfigs) {
|
|
for (const domain of domainConfig.domains) {
|
|
const isWildcard = domain.includes('*');
|
|
let provision: string | plugins.tsclass.network.ICert = 'http01';
|
|
if (this.settings.certProvisionFunction) {
|
|
try {
|
|
provision = await this.settings.certProvisionFunction(domain);
|
|
} catch (err) {
|
|
console.log(`certProvider error for ${domain}: ${err}`);
|
|
}
|
|
} else if (isWildcard) {
|
|
console.warn(`Skipping wildcard domain without certProvisionFunction: ${domain}`);
|
|
continue;
|
|
}
|
|
if (provision === 'http01') {
|
|
if (isWildcard) {
|
|
console.warn(`Skipping HTTP-01 for wildcard domain: ${domain}`);
|
|
continue;
|
|
}
|
|
this.port80Handler.addDomain({
|
|
domainName: domain,
|
|
sslRedirect: true,
|
|
acmeMaintenance: true
|
|
});
|
|
console.log(`Registered domain ${domain} with Port80Handler for HTTP-01`);
|
|
} else {
|
|
// Static certificate (e.g., DNS-01 provisioned) supports wildcards
|
|
const certObj = provision as plugins.tsclass.network.ICert;
|
|
const certData: ICertificateData = {
|
|
domain: certObj.domainName,
|
|
certificate: certObj.publicKey,
|
|
privateKey: certObj.privateKey,
|
|
expiryDate: new Date(certObj.validUntil)
|
|
};
|
|
this.networkProxyBridge.applyExternalCertificate(certData);
|
|
console.log(`Applied static certificate for ${domain} from certProvider`);
|
|
}
|
|
}
|
|
}
|
|
console.log('Provisioned certificates for new domains');
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// Use Port80Handler if available
|
|
if (this.port80Handler) {
|
|
try {
|
|
// Check if we already have a certificate
|
|
const cert = this.port80Handler.getCertificate(domain);
|
|
if (cert) {
|
|
console.log(`Certificate already exists for ${domain}, valid until ${cert.expiryDate.toISOString()}`);
|
|
return true;
|
|
}
|
|
|
|
// Register domain for certificate issuance
|
|
this.port80Handler.addDomain({
|
|
domainName: domain,
|
|
sslRedirect: true,
|
|
acmeMaintenance: true
|
|
});
|
|
|
|
console.log(`Domain ${domain} registered for certificate issuance`);
|
|
return true;
|
|
} catch (err) {
|
|
console.log(`Error registering domain with Port80Handler: ${err}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Fall back to NetworkProxyBridge
|
|
return this.networkProxyBridge.requestCertificate(domain);
|
|
}
|
|
|
|
/**
|
|
* Validates if a domain name is valid for certificate issuance
|
|
*/
|
|
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 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,
|
|
acmeEnabled: !!this.port80Handler,
|
|
port80HandlerPort: this.port80Handler ? this.settings.acme?.port : null
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get a list of eligible domains for ACME certificates
|
|
*/
|
|
public getEligibleDomainsForCertificates(): string[] {
|
|
// Collect all non-wildcard 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;
|
|
}
|
|
|
|
/**
|
|
* Get status of certificates managed by Port80Handler
|
|
*/
|
|
public getCertificateStatus(): any {
|
|
if (!this.port80Handler) {
|
|
return {
|
|
enabled: false,
|
|
message: 'Port80Handler is not enabled'
|
|
};
|
|
}
|
|
|
|
// Get eligible domains
|
|
const eligibleDomains = this.getEligibleDomainsForCertificates();
|
|
const certificateStatus: Record<string, any> = {};
|
|
|
|
// Check each domain
|
|
for (const domain of eligibleDomains) {
|
|
const cert = this.port80Handler.getCertificate(domain);
|
|
|
|
if (cert) {
|
|
const now = new Date();
|
|
const expiryDate = cert.expiryDate;
|
|
const daysRemaining = Math.floor((expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000));
|
|
|
|
certificateStatus[domain] = {
|
|
status: 'valid',
|
|
expiryDate: expiryDate.toISOString(),
|
|
daysRemaining,
|
|
renewalNeeded: daysRemaining <= (this.settings.acme?.renewThresholdDays ?? 0)
|
|
};
|
|
} else {
|
|
certificateStatus[domain] = {
|
|
status: 'missing',
|
|
message: 'No certificate found'
|
|
};
|
|
}
|
|
}
|
|
|
|
const acme = this.settings.acme!;
|
|
return {
|
|
enabled: true,
|
|
port: acme.port!,
|
|
useProduction: acme.useProduction!,
|
|
autoRenew: acme.autoRenew!,
|
|
certificates: certificateStatus
|
|
};
|
|
}
|
|
} |