smartproxy/ts/smartproxy/classes.smartproxy.ts

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
};
}
}