import * as plugins from '../plugins.js'; import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { type INetworkProxyOptions, type ICertificateEntry, type ILogger, createLogger } from './classes.np.types.js'; import { Port80Handler, Port80HandlerEvents, type IDomainOptions } from '../port80handler/classes.port80handler.js'; /** * Manages SSL certificates for NetworkProxy including ACME integration */ export class CertificateManager { private defaultCertificates: { key: string; cert: string }; private certificateCache: Map = new Map(); private port80Handler: Port80Handler | null = null; private externalPort80Handler: boolean = false; private certificateStoreDir: string; private logger: ILogger; private httpsServer: plugins.https.Server | null = null; constructor(private options: INetworkProxyOptions) { this.certificateStoreDir = path.resolve(options.acme?.certificateStore || './certs'); this.logger = createLogger(options.logLevel || 'info'); // Ensure certificate store directory exists try { if (!fs.existsSync(this.certificateStoreDir)) { fs.mkdirSync(this.certificateStoreDir, { recursive: true }); this.logger.info(`Created certificate store directory: ${this.certificateStoreDir}`); } } catch (error) { this.logger.warn(`Failed to create certificate store directory: ${error}`); } this.loadDefaultCertificates(); } /** * Loads default certificates from the filesystem */ public loadDefaultCertificates(): void { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const certPath = path.join(__dirname, '..', '..', 'assets', 'certs'); try { this.defaultCertificates = { key: fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'), cert: fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8') }; this.logger.info('Default certificates loaded successfully'); } catch (error) { this.logger.error('Error loading default certificates', error); // Generate self-signed fallback certificates try { // This is a placeholder for actual certificate generation code // In a real implementation, you would use a library like selfsigned to generate certs this.defaultCertificates = { key: "FALLBACK_KEY_CONTENT", cert: "FALLBACK_CERT_CONTENT" }; this.logger.warn('Using fallback self-signed certificates'); } catch (fallbackError) { this.logger.error('Failed to generate fallback certificates', fallbackError); throw new Error('Could not load or generate SSL certificates'); } } } /** * Set the HTTPS server reference for context updates */ public setHttpsServer(server: plugins.https.Server): void { this.httpsServer = server; } /** * Get default certificates */ public getDefaultCertificates(): { key: string; cert: string } { return { ...this.defaultCertificates }; } /** * Sets an external Port80Handler for certificate management */ public setExternalPort80Handler(handler: Port80Handler): void { if (this.port80Handler && !this.externalPort80Handler) { this.logger.warn('Replacing existing internal Port80Handler with external handler'); // Clean up existing handler if needed if (this.port80Handler !== handler) { // Unregister event handlers to avoid memory leaks this.port80Handler.removeAllListeners(Port80HandlerEvents.CERTIFICATE_ISSUED); this.port80Handler.removeAllListeners(Port80HandlerEvents.CERTIFICATE_RENEWED); this.port80Handler.removeAllListeners(Port80HandlerEvents.CERTIFICATE_FAILED); this.port80Handler.removeAllListeners(Port80HandlerEvents.CERTIFICATE_EXPIRING); } } // Set the external handler this.port80Handler = handler; this.externalPort80Handler = true; // Register event handlers this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, this.handleCertificateIssued.bind(this)); this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, this.handleCertificateIssued.bind(this)); this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, this.handleCertificateFailed.bind(this)); this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, (data) => { this.logger.info(`Certificate for ${data.domain} expires in ${data.daysRemaining} days`); }); this.logger.info('External Port80Handler connected to CertificateManager'); // Register domains with Port80Handler if we have any certificates cached if (this.certificateCache.size > 0) { const domains = Array.from(this.certificateCache.keys()) .filter(domain => !domain.includes('*')); // Skip wildcard domains this.registerDomainsWithPort80Handler(domains); } } /** * Handle newly issued or renewed certificates from Port80Handler */ private handleCertificateIssued(data: { domain: string; certificate: string; privateKey: string; expiryDate: Date }): void { const { domain, certificate, privateKey, expiryDate } = data; this.logger.info(`Certificate ${this.certificateCache.has(domain) ? 'renewed' : 'issued'} for ${domain}, valid until ${expiryDate.toISOString()}`); // Update certificate in HTTPS server this.updateCertificateCache(domain, certificate, privateKey, expiryDate); // Save the certificate to the filesystem if not using external handler if (!this.externalPort80Handler && this.options.acme?.certificateStore) { this.saveCertificateToStore(domain, certificate, privateKey); } } /** * Handle certificate issuance failures */ private handleCertificateFailed(data: { domain: string; error: string }): void { this.logger.error(`Certificate issuance failed for ${data.domain}: ${data.error}`); } /** * Saves certificate and private key to the filesystem */ private saveCertificateToStore(domain: string, certificate: string, privateKey: string): void { try { const certPath = path.join(this.certificateStoreDir, `${domain}.cert.pem`); const keyPath = path.join(this.certificateStoreDir, `${domain}.key.pem`); fs.writeFileSync(certPath, certificate); fs.writeFileSync(keyPath, privateKey); // Ensure private key has restricted permissions try { fs.chmodSync(keyPath, 0o600); } catch (error) { this.logger.warn(`Failed to set permissions on private key for ${domain}: ${error}`); } this.logger.info(`Saved certificate for ${domain} to ${certPath}`); } catch (error) { this.logger.error(`Failed to save certificate for ${domain}: ${error}`); } } /** * Handles SNI (Server Name Indication) for TLS connections * Used by the HTTPS server to select the correct certificate for each domain */ public handleSNI(domain: string, cb: (err: Error | null, ctx: plugins.tls.SecureContext) => void): void { this.logger.debug(`SNI request for domain: ${domain}`); // Check if we have a certificate for this domain const certs = this.certificateCache.get(domain); if (certs) { try { // Create TLS context with the cached certificate const context = plugins.tls.createSecureContext({ key: certs.key, cert: certs.cert }); this.logger.debug(`Using cached certificate for ${domain}`); cb(null, context); return; } catch (err) { this.logger.error(`Error creating secure context for ${domain}:`, err); } } // Check if we should trigger certificate issuance if (this.options.acme?.enabled && this.port80Handler && !domain.includes('*')) { // Check if this domain is already registered const certData = this.port80Handler.getCertificate(domain); if (!certData) { this.logger.info(`No certificate found for ${domain}, registering for issuance`); // Register with new domain options format const domainOptions: IDomainOptions = { domainName: domain, sslRedirect: true, acmeMaintenance: true }; this.port80Handler.addDomain(domainOptions); } } // Fall back to default certificate try { const context = plugins.tls.createSecureContext({ key: this.defaultCertificates.key, cert: this.defaultCertificates.cert }); this.logger.debug(`Using default certificate for ${domain}`); cb(null, context); } catch (err) { this.logger.error(`Error creating default secure context:`, err); cb(new Error('Cannot create secure context'), null); } } /** * Updates certificate in cache */ public updateCertificateCache(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void { // Update certificate context in HTTPS server if it's running if (this.httpsServer) { try { this.httpsServer.addContext(domain, { key: privateKey, cert: certificate }); this.logger.debug(`Updated SSL context for domain: ${domain}`); } catch (error) { this.logger.error(`Error updating SSL context for domain ${domain}:`, error); } } // Update certificate in cache this.certificateCache.set(domain, { key: privateKey, cert: certificate, expires: expiryDate }); } /** * Gets a certificate for a domain */ public getCertificate(domain: string): ICertificateEntry | undefined { return this.certificateCache.get(domain); } /** * Requests a new certificate for a domain */ public async requestCertificate(domain: string): Promise { if (!this.options.acme?.enabled && !this.externalPort80Handler) { this.logger.warn('ACME certificate management is not enabled'); return false; } if (!this.port80Handler) { this.logger.error('Port80Handler is not initialized'); return false; } // Skip wildcard domains - can't get certs for these with HTTP-01 validation if (domain.includes('*')) { this.logger.error(`Cannot request certificate for wildcard domain: ${domain}`); return false; } try { // Use the new domain options format const domainOptions: IDomainOptions = { domainName: domain, sslRedirect: true, acmeMaintenance: true }; this.port80Handler.addDomain(domainOptions); this.logger.info(`Certificate request submitted for domain: ${domain}`); return true; } catch (error) { this.logger.error(`Error requesting certificate for domain ${domain}:`, error); return false; } } /** * Registers domains with Port80Handler for ACME certificate management */ public registerDomainsWithPort80Handler(domains: string[]): void { if (!this.port80Handler) { this.logger.warn('Port80Handler is not initialized'); return; } for (const domain of domains) { // Skip wildcard domains - can't get certs for these with HTTP-01 validation if (domain.includes('*')) { this.logger.info(`Skipping wildcard domain for ACME: ${domain}`); continue; } // Skip domains already with certificates if configured to do so if (this.options.acme?.skipConfiguredCerts) { const cachedCert = this.certificateCache.get(domain); if (cachedCert) { this.logger.info(`Skipping domain with existing certificate: ${domain}`); continue; } } // Register the domain for certificate issuance with new domain options format const domainOptions: IDomainOptions = { domainName: domain, sslRedirect: true, acmeMaintenance: true }; this.port80Handler.addDomain(domainOptions); this.logger.info(`Registered domain for ACME certificate issuance: ${domain}`); } } /** * Initialize internal Port80Handler */ public async initializePort80Handler(): Promise { // Skip if using external handler if (this.externalPort80Handler) { this.logger.info('Using external Port80Handler, skipping initialization'); return this.port80Handler; } if (!this.options.acme?.enabled) { return null; } // Create certificate manager this.port80Handler = new Port80Handler({ port: this.options.acme.port, contactEmail: this.options.acme.contactEmail, useProduction: this.options.acme.useProduction, renewThresholdDays: this.options.acme.renewThresholdDays, httpsRedirectPort: this.options.port, // Redirect to our HTTPS port renewCheckIntervalHours: 24, // Check daily for renewals enabled: this.options.acme.enabled, autoRenew: this.options.acme.autoRenew, certificateStore: this.options.acme.certificateStore, skipConfiguredCerts: this.options.acme.skipConfiguredCerts }); // Register event handlers this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, this.handleCertificateIssued.bind(this)); this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, this.handleCertificateIssued.bind(this)); this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, this.handleCertificateFailed.bind(this)); this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, (data) => { this.logger.info(`Certificate for ${data.domain} expires in ${data.daysRemaining} days`); }); // Start the handler try { await this.port80Handler.start(); this.logger.info(`Port80Handler started on port ${this.options.acme.port}`); return this.port80Handler; } catch (error) { this.logger.error(`Failed to start Port80Handler: ${error}`); this.port80Handler = null; return null; } } /** * Stop the Port80Handler if it was internally created */ public async stopPort80Handler(): Promise { if (this.port80Handler && !this.externalPort80Handler) { try { await this.port80Handler.stop(); this.logger.info('Port80Handler stopped'); } catch (error) { this.logger.error('Error stopping Port80Handler', error); } } } }