BREAKING CHANGE(certProvisioner): Refactor: Introduce unified CertProvisioner to centralize certificate provisioning and renewal; remove legacy ACME config from Port80Handler and update SmartProxy to delegate certificate lifecycle management.
This commit is contained in:
		| @@ -3,6 +3,6 @@ | ||||
|  */ | ||||
| export const commitinfo = { | ||||
|   name: '@push.rocks/smartproxy', | ||||
|   version: '7.2.0', | ||||
|   version: '8.0.0', | ||||
|   description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.' | ||||
| } | ||||
|   | ||||
| @@ -353,11 +353,8 @@ export class CertificateManager { | ||||
|       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 | ||||
|     }); | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import * as plugins from '../plugins.js'; | ||||
| import { IncomingMessage, ServerResponse } from 'http'; | ||||
| import * as fs from 'fs'; | ||||
| import * as path from 'path'; | ||||
| // (fs and path I/O moved to CertProvisioner) | ||||
| // ACME HTTP-01 challenge handler storing tokens in memory (diskless) | ||||
| class DisklessHttp01Handler { | ||||
|   private storage: Map<string, string>; | ||||
| @@ -87,9 +86,7 @@ interface IPort80HandlerOptions { | ||||
|   useProduction?: boolean; | ||||
|   httpsRedirectPort?: number; | ||||
|   enabled?: boolean; // Whether ACME is enabled at all | ||||
|   autoRenew?: boolean; // Whether to automatically renew certificates | ||||
|   certificateStore?: string; // Directory to store certificates | ||||
|   skipConfiguredCerts?: boolean; // Skip domains that already have certificates | ||||
|   // (Persistence moved to CertProvisioner) | ||||
| } | ||||
|  | ||||
| /** | ||||
| @@ -163,10 +160,7 @@ export class Port80Handler extends plugins.EventEmitter { | ||||
|       contactEmail: options.contactEmail ?? 'admin@example.com', | ||||
|       useProduction: options.useProduction ?? false, // Safer default: staging | ||||
|       httpsRedirectPort: options.httpsRedirectPort ?? 443, | ||||
|       enabled: options.enabled ?? true, // Enable by default | ||||
|       autoRenew: options.autoRenew ?? true, // Auto-renew by default | ||||
|       certificateStore: options.certificateStore ?? './certs', // Default store location | ||||
|       skipConfiguredCerts: options.skipConfiguredCerts ?? false | ||||
|       enabled: options.enabled ?? true // Enable by default | ||||
|     }; | ||||
|   } | ||||
|  | ||||
| @@ -201,10 +195,6 @@ export class Port80Handler extends plugins.EventEmitter { | ||||
|  | ||||
|     return new Promise((resolve, reject) => { | ||||
|       try { | ||||
|         // Load certificates from store if enabled | ||||
|         if (this.options.certificateStore) { | ||||
|           this.loadCertificatesFromStore(); | ||||
|         } | ||||
|          | ||||
|         this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res)); | ||||
|          | ||||
| @@ -370,10 +360,7 @@ export class Port80Handler extends plugins.EventEmitter { | ||||
|      | ||||
|     console.log(`Certificate set for ${domain}`); | ||||
|      | ||||
|     // Save certificate to store if enabled | ||||
|     if (this.options.certificateStore) { | ||||
|       this.saveCertificateToStore(domain, certificate, privateKey); | ||||
|     } | ||||
|     // (Persistence of certificates moved to CertProvisioner) | ||||
|      | ||||
|     // Emit certificate event | ||||
|     this.emitCertificateEvent(Port80HandlerEvents.CERTIFICATE_ISSUED, { | ||||
| @@ -408,134 +395,7 @@ export class Port80Handler extends plugins.EventEmitter { | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Saves a certificate to the filesystem store | ||||
|    * @param domain The domain for the certificate | ||||
|    * @param certificate The certificate (PEM format) | ||||
|    * @param privateKey The private key (PEM format) | ||||
|    * @private | ||||
|    */ | ||||
|   private saveCertificateToStore(domain: string, certificate: string, privateKey: string): void { | ||||
|     // Skip if certificate store is not enabled | ||||
|     if (!this.options.certificateStore) return; | ||||
|      | ||||
|     try { | ||||
|       const storePath = this.options.certificateStore; | ||||
|        | ||||
|       // Ensure the directory exists | ||||
|       if (!fs.existsSync(storePath)) { | ||||
|         fs.mkdirSync(storePath, { recursive: true }); | ||||
|         console.log(`Created certificate store directory: ${storePath}`); | ||||
|       } | ||||
|        | ||||
|       const certPath = path.join(storePath, `${domain}.cert.pem`); | ||||
|       const keyPath = path.join(storePath, `${domain}.key.pem`); | ||||
|        | ||||
|       // Write certificate and private key files | ||||
|       fs.writeFileSync(certPath, certificate); | ||||
|       fs.writeFileSync(keyPath, privateKey); | ||||
|        | ||||
|       // Set secure permissions for private key | ||||
|       try { | ||||
|         fs.chmodSync(keyPath, 0o600); | ||||
|       } catch (err) { | ||||
|         console.log(`Warning: Could not set secure permissions on ${keyPath}`); | ||||
|       } | ||||
|        | ||||
|       console.log(`Saved certificate for ${domain} to ${certPath}`); | ||||
|     } catch (err) { | ||||
|       console.error(`Error saving certificate for ${domain}:`, err); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Loads certificates from the certificate store | ||||
|    * @private | ||||
|    */ | ||||
|   private loadCertificatesFromStore(): void { | ||||
|     if (!this.options.certificateStore) return; | ||||
|      | ||||
|     try { | ||||
|       const storePath = this.options.certificateStore; | ||||
|        | ||||
|       // Ensure the directory exists | ||||
|       if (!fs.existsSync(storePath)) { | ||||
|         fs.mkdirSync(storePath, { recursive: true }); | ||||
|         console.log(`Created certificate store directory: ${storePath}`); | ||||
|         return; | ||||
|       } | ||||
|        | ||||
|       // Get list of certificate files | ||||
|       const files = fs.readdirSync(storePath); | ||||
|       const certFiles = files.filter(file => file.endsWith('.cert.pem')); | ||||
|        | ||||
|       // Load each certificate | ||||
|       for (const certFile of certFiles) { | ||||
|         const domain = certFile.replace('.cert.pem', ''); | ||||
|         const keyFile = `${domain}.key.pem`; | ||||
|          | ||||
|         // Skip if key file doesn't exist | ||||
|         if (!files.includes(keyFile)) { | ||||
|           console.log(`Warning: Found certificate for ${domain} but no key file`); | ||||
|           continue; | ||||
|         } | ||||
|          | ||||
|         // Skip if we should skip configured certs | ||||
|         if (this.options.skipConfiguredCerts) { | ||||
|           const domainInfo = this.domainCertificates.get(domain); | ||||
|           if (domainInfo && domainInfo.certObtained) { | ||||
|             console.log(`Skipping already configured certificate for ${domain}`); | ||||
|             continue; | ||||
|           } | ||||
|         } | ||||
|          | ||||
|         // Load certificate and key | ||||
|         try { | ||||
|           const certificate = fs.readFileSync(path.join(storePath, certFile), 'utf8'); | ||||
|           const privateKey = fs.readFileSync(path.join(storePath, keyFile), 'utf8'); | ||||
|            | ||||
|           // Extract expiry date | ||||
|           let expiryDate: Date | undefined; | ||||
|           try { | ||||
|             const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i); | ||||
|             if (matches && matches[1]) { | ||||
|               expiryDate = new Date(matches[1]); | ||||
|             } | ||||
|           } catch (err) { | ||||
|             console.log(`Warning: Could not extract expiry date from certificate for ${domain}`); | ||||
|           } | ||||
|            | ||||
|           // Check if domain is already registered | ||||
|           let domainInfo = this.domainCertificates.get(domain); | ||||
|           if (!domainInfo) { | ||||
|             // Register domain if not already registered | ||||
|             domainInfo = { | ||||
|               options: { | ||||
|                 domainName: domain, | ||||
|                 sslRedirect: true, | ||||
|                 acmeMaintenance: true | ||||
|               }, | ||||
|               certObtained: false, | ||||
|               obtainingInProgress: false | ||||
|             }; | ||||
|             this.domainCertificates.set(domain, domainInfo); | ||||
|           } | ||||
|            | ||||
|           // Set certificate | ||||
|           domainInfo.certificate = certificate; | ||||
|           domainInfo.privateKey = privateKey; | ||||
|           domainInfo.certObtained = true; | ||||
|           domainInfo.expiryDate = expiryDate; | ||||
|            | ||||
|           console.log(`Loaded certificate for ${domain} from store, valid until ${expiryDate?.toISOString() || 'unknown'}`); | ||||
|         } catch (err) { | ||||
|           console.error(`Error loading certificate for ${domain}:`, err); | ||||
|         } | ||||
|       } | ||||
|     } catch (err) { | ||||
|       console.error('Error loading certificates from store:', err); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check if a domain is a glob pattern | ||||
| @@ -625,13 +485,19 @@ export class Port80Handler extends plugins.EventEmitter { | ||||
|     const { domainInfo, pattern } = domainMatch; | ||||
|     const options = domainInfo.options; | ||||
|  | ||||
|     // Serve or forward ACME HTTP-01 challenge requests | ||||
|     if (req.url && req.url.startsWith('/.well-known/acme-challenge/') && options.acmeMaintenance) { | ||||
|     // Handle ACME HTTP-01 challenge requests or forwarding | ||||
|     if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) { | ||||
|       // Forward ACME requests if configured | ||||
|       if (options.acmeForward) { | ||||
|         this.forwardRequest(req, res, options.acmeForward, 'ACME challenge'); | ||||
|         return; | ||||
|       } | ||||
|       // If not managing ACME for this domain, return 404 | ||||
|       if (!options.acmeMaintenance) { | ||||
|         res.statusCode = 404; | ||||
|         res.end('Not found'); | ||||
|         return; | ||||
|       } | ||||
|       // Serve challenge response from in-memory storage | ||||
|       const token = req.url.split('/').pop() || ''; | ||||
|       const keyAuth = this.acmeHttp01Storage.get(token); | ||||
| @@ -795,9 +661,7 @@ export class Port80Handler extends plugins.EventEmitter { | ||||
|       domainInfo.expiryDate = expiryDate; | ||||
|  | ||||
|       console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`); | ||||
|       if (this.options.certificateStore) { | ||||
|         this.saveCertificateToStore(domain, certificate, privateKey); | ||||
|       } | ||||
|       // Persistence moved to CertProvisioner | ||||
|       const eventType = isRenewal | ||||
|         ? Port80HandlerEvents.CERTIFICATE_RENEWED | ||||
|         : Port80HandlerEvents.CERTIFICATE_ISSUED; | ||||
|   | ||||
							
								
								
									
										183
									
								
								ts/smartproxy/classes.pp.certprovisioner.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										183
									
								
								ts/smartproxy/classes.pp.certprovisioner.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,183 @@ | ||||
| import * as plugins from '../plugins.js'; | ||||
| import type { IDomainConfig, ISmartProxyCertProvisionObject } from './classes.pp.interfaces.js'; | ||||
| import { Port80Handler, Port80HandlerEvents, type ICertificateData } from '../port80handler/classes.port80handler.js'; | ||||
| import type { NetworkProxyBridge } from './classes.pp.networkproxybridge.js'; | ||||
|  | ||||
| /** | ||||
|  * CertProvisioner manages certificate provisioning and renewal workflows, | ||||
|  * unifying static certificates and HTTP-01 challenges via Port80Handler. | ||||
|  */ | ||||
| export class CertProvisioner extends plugins.EventEmitter { | ||||
|   private domainConfigs: IDomainConfig[]; | ||||
|   private port80Handler: Port80Handler; | ||||
|   private networkProxyBridge: NetworkProxyBridge; | ||||
|   private certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>; | ||||
|   private forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }>; | ||||
|   private renewThresholdDays: number; | ||||
|   private renewCheckIntervalHours: number; | ||||
|   private autoRenew: boolean; | ||||
|   private renewManager?: plugins.taskbuffer.TaskManager; | ||||
|   // Track provisioning type per domain: 'http01' or 'static' | ||||
|   private provisionMap: Map<string, 'http01' | 'static'>; | ||||
|  | ||||
|   /** | ||||
|    * @param domainConfigs Array of domain configuration objects | ||||
|    * @param port80Handler HTTP-01 challenge handler instance | ||||
|    * @param networkProxyBridge Bridge for applying external certificates | ||||
|    * @param certProvider Optional callback returning a static cert or 'http01' | ||||
|    * @param renewThresholdDays Days before expiry to trigger renewals | ||||
|    * @param renewCheckIntervalHours Interval in hours to check for renewals | ||||
|    * @param autoRenew Whether to automatically schedule renewals | ||||
|    */ | ||||
|   constructor( | ||||
|     domainConfigs: IDomainConfig[], | ||||
|     port80Handler: Port80Handler, | ||||
|     networkProxyBridge: NetworkProxyBridge, | ||||
|     certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>, | ||||
|     renewThresholdDays: number = 30, | ||||
|     renewCheckIntervalHours: number = 24, | ||||
|     autoRenew: boolean = true, | ||||
|     forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }> = [] | ||||
|   ) { | ||||
|     super(); | ||||
|     this.domainConfigs = domainConfigs; | ||||
|     this.port80Handler = port80Handler; | ||||
|     this.networkProxyBridge = networkProxyBridge; | ||||
|     this.certProvider = certProvider; | ||||
|     this.renewThresholdDays = renewThresholdDays; | ||||
|     this.renewCheckIntervalHours = renewCheckIntervalHours; | ||||
|     this.autoRenew = autoRenew; | ||||
|     this.provisionMap = new Map(); | ||||
|     this.forwardConfigs = forwardConfigs; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Start initial provisioning and schedule renewals. | ||||
|    */ | ||||
|   public async start(): Promise<void> { | ||||
|     // Subscribe to Port80Handler certificate events | ||||
|     this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (data: ICertificateData) => { | ||||
|       this.emit('certificate', { ...data, source: 'http01', isRenewal: false }); | ||||
|     }); | ||||
|     this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (data: ICertificateData) => { | ||||
|       this.emit('certificate', { ...data, source: 'http01', isRenewal: true }); | ||||
|     }); | ||||
|  | ||||
|     // Apply external forwarding for ACME challenges (e.g. Synology) | ||||
|     for (const f of this.forwardConfigs) { | ||||
|       this.port80Handler.addDomain({ | ||||
|         domainName: f.domain, | ||||
|         sslRedirect: f.sslRedirect, | ||||
|         acmeMaintenance: false, | ||||
|         forward: f.forwardConfig, | ||||
|         acmeForward: f.acmeForwardConfig | ||||
|       }); | ||||
|     } | ||||
|     // Initial provisioning for all domains | ||||
|     const domains = this.domainConfigs.flatMap(cfg => cfg.domains); | ||||
|     for (const domain of domains) { | ||||
|       // Skip wildcard domains | ||||
|       if (domain.includes('*')) continue; | ||||
|       let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01'; | ||||
|       if (this.certProvider) { | ||||
|         try { | ||||
|           provision = await this.certProvider(domain); | ||||
|         } catch (err) { | ||||
|           console.error(`certProvider error for ${domain}:`, err); | ||||
|         } | ||||
|       } | ||||
|       if (provision === 'http01') { | ||||
|         this.provisionMap.set(domain, 'http01'); | ||||
|         this.port80Handler.addDomain({ domainName: domain, sslRedirect: true, acmeMaintenance: true }); | ||||
|       } else { | ||||
|         this.provisionMap.set(domain, 'static'); | ||||
|         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); | ||||
|         this.emit('certificate', { ...certData, source: 'static', isRenewal: false }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Schedule renewals if enabled | ||||
|     if (this.autoRenew) { | ||||
|       this.renewManager = new plugins.taskbuffer.TaskManager(); | ||||
|       const renewTask = new plugins.taskbuffer.Task({ | ||||
|         name: 'CertificateRenewals', | ||||
|         taskFunction: async () => { | ||||
|           for (const [domain, type] of this.provisionMap.entries()) { | ||||
|             // Skip wildcard domains | ||||
|             if (domain.includes('*')) continue; | ||||
|             try { | ||||
|               if (type === 'http01') { | ||||
|                 await this.port80Handler.renewCertificate(domain); | ||||
|               } else if (type === 'static' && this.certProvider) { | ||||
|                 const provision2 = await this.certProvider(domain); | ||||
|                 if (provision2 !== 'http01') { | ||||
|                   const certObj = provision2 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); | ||||
|                   this.emit('certificate', { ...certData, source: 'static', isRenewal: true }); | ||||
|                 } | ||||
|               } | ||||
|             } catch (err) { | ||||
|               console.error(`Renewal error for ${domain}:`, err); | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       }); | ||||
|       const hours = this.renewCheckIntervalHours; | ||||
|       const cronExpr = `0 0 */${hours} * * *`; | ||||
|       this.renewManager.addAndScheduleTask(renewTask, cronExpr); | ||||
|       this.renewManager.start(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Stop all scheduled renewal tasks. | ||||
|    */ | ||||
|   public async stop(): Promise<void> { | ||||
|     // Stop scheduled renewals | ||||
|     if (this.renewManager) { | ||||
|       this.renewManager.stop(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Request a certificate on-demand for the given domain. | ||||
|    * @param domain Domain name to provision | ||||
|    */ | ||||
|   public async requestCertificate(domain: string): Promise<void> { | ||||
|     // Skip wildcard domains | ||||
|     if (domain.includes('*')) { | ||||
|       throw new Error(`Cannot request certificate for wildcard domain: ${domain}`); | ||||
|     } | ||||
|     // Determine provisioning method | ||||
|     let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01'; | ||||
|     if (this.certProvider) { | ||||
|       provision = await this.certProvider(domain); | ||||
|     } | ||||
|     if (provision === 'http01') { | ||||
|       await this.port80Handler.renewCertificate(domain); | ||||
|     } else { | ||||
|       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); | ||||
|       this.emit('certificate', { ...certData, source: 'static', isRenewal: false }); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -109,17 +109,6 @@ export interface IPortProxySettings { | ||||
|     }>; | ||||
|   }; | ||||
|    | ||||
|   // Legacy ACME configuration (deprecated, use port80HandlerConfig instead) | ||||
|   acme?: { | ||||
|     enabled?: boolean; | ||||
|     port?: number; | ||||
|     contactEmail?: string; | ||||
|     useProduction?: boolean; | ||||
|     renewThresholdDays?: number; | ||||
|     autoRenew?: boolean; | ||||
|     certificateStore?: string; | ||||
|     skipConfiguredCerts?: boolean; | ||||
|   }; | ||||
|   /** | ||||
|    * Optional certificate provider callback. Return 'http01' to use HTTP-01 challenges, | ||||
|    * or a static certificate object for immediate provisioning. | ||||
|   | ||||
| @@ -43,10 +43,6 @@ export class NetworkProxyBridge { | ||||
|         useExternalPort80Handler: !!this.port80Handler // Use Port80Handler if available | ||||
|       }; | ||||
|  | ||||
|       // Copy ACME settings for backward compatibility (if port80HandlerConfig not set) | ||||
|       if (!this.settings.port80HandlerConfig && this.settings.acme) { | ||||
|         networkProxyOptions.acme = { ...this.settings.acme }; | ||||
|       } | ||||
|  | ||||
|       this.networkProxy = new NetworkProxy(networkProxyOptions); | ||||
|  | ||||
| @@ -288,7 +284,7 @@ export class NetworkProxyBridge { | ||||
|       ); | ||||
|  | ||||
|       // Log ACME-eligible domains | ||||
|       const acmeEnabled = this.settings.port80HandlerConfig?.enabled || this.settings.acme?.enabled; | ||||
|       const acmeEnabled = !!this.settings.port80HandlerConfig?.enabled; | ||||
|       if (acmeEnabled) { | ||||
|         const acmeEligibleDomains = proxyConfigs | ||||
|           .filter((config) => !config.hostName.includes('*')) // Exclude wildcards | ||||
| @@ -349,7 +345,7 @@ export class NetworkProxyBridge { | ||||
|       return false; | ||||
|     } | ||||
|  | ||||
|     if (!this.settings.port80HandlerConfig?.enabled && !this.settings.acme?.enabled) { | ||||
|     if (!this.settings.port80HandlerConfig?.enabled) { | ||||
|       console.log('Cannot request certificate - ACME is not enabled'); | ||||
|       return false; | ||||
|     } | ||||
|   | ||||
| @@ -117,10 +117,6 @@ export class PortRangeManager { | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // 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) { | ||||
| @@ -202,12 +198,6 @@ export class PortRangeManager { | ||||
|       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; | ||||
|   } | ||||
|   | ||||
| @@ -8,7 +8,9 @@ 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, Port80HandlerEvents, type ICertificateData } from '../port80handler/classes.port80handler.js'; | ||||
| import { Port80Handler } from '../port80handler/classes.port80handler.js'; | ||||
| import { CertProvisioner } from './classes.pp.certprovisioner.js'; | ||||
| import type { ICertificateData } from '../port80handler/classes.port80handler.js'; | ||||
| import * as path from 'path'; | ||||
| import * as fs from 'fs'; | ||||
|  | ||||
| @@ -32,8 +34,8 @@ export class SmartProxy extends plugins.EventEmitter { | ||||
|    | ||||
|   // Port80Handler for ACME certificate management | ||||
|   private port80Handler: Port80Handler | null = null; | ||||
|   // Renewal scheduler for certificates | ||||
|   private renewManager?: plugins.taskbuffer.TaskManager; | ||||
|   // CertProvisioner for unified certificate workflows | ||||
|   private certProvisioner?: CertProvisioner; | ||||
|    | ||||
|   constructor(settingsArg: IPortProxySettings) { | ||||
|     super(); | ||||
| @@ -69,37 +71,20 @@ export class SmartProxy extends plugins.EventEmitter { | ||||
|       globalPortRanges: settingsArg.globalPortRanges || [], | ||||
|     }; | ||||
|      | ||||
|     // Set port80HandlerConfig defaults, using legacy acme config if available | ||||
|     // Set default port80HandlerConfig if not provided | ||||
|     if (!this.settings.port80HandlerConfig || Object.keys(this.settings.port80HandlerConfig).length === 0) { | ||||
|       if (this.settings.acme) { | ||||
|         // Migrate from legacy acme config | ||||
|         this.settings.port80HandlerConfig = { | ||||
|           enabled: this.settings.acme.enabled, | ||||
|           port: this.settings.acme.port || 80, | ||||
|           contactEmail: this.settings.acme.contactEmail || 'admin@example.com', | ||||
|           useProduction: this.settings.acme.useProduction || false, | ||||
|           renewThresholdDays: this.settings.acme.renewThresholdDays || 30, | ||||
|           autoRenew: this.settings.acme.autoRenew !== false, // Default to true | ||||
|           certificateStore: this.settings.acme.certificateStore || './certs', | ||||
|           skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false, | ||||
|           httpsRedirectPort: this.settings.fromPort, | ||||
|           renewCheckIntervalHours: 24 | ||||
|         }; | ||||
|       } else { | ||||
|         // Set defaults if no config provided | ||||
|         this.settings.port80HandlerConfig = { | ||||
|           enabled: false, | ||||
|           port: 80, | ||||
|           contactEmail: 'admin@example.com', | ||||
|           useProduction: false, | ||||
|           renewThresholdDays: 30, | ||||
|           autoRenew: true, | ||||
|           certificateStore: './certs', | ||||
|           skipConfiguredCerts: false, | ||||
|           httpsRedirectPort: this.settings.fromPort, | ||||
|           renewCheckIntervalHours: 24 | ||||
|         }; | ||||
|       } | ||||
|       this.settings.port80HandlerConfig = { | ||||
|         enabled: false, | ||||
|         port: 80, | ||||
|         contactEmail: 'admin@example.com', | ||||
|         useProduction: false, | ||||
|         renewThresholdDays: 30, | ||||
|         autoRenew: true, | ||||
|         certificateStore: './certs', | ||||
|         skipConfiguredCerts: false, | ||||
|         httpsRedirectPort: this.settings.fromPort, | ||||
|         renewCheckIntervalHours: 24 | ||||
|       }; | ||||
|     } | ||||
|      | ||||
|     // Initialize component managers | ||||
| @@ -161,96 +146,11 @@ export class SmartProxy extends plugins.EventEmitter { | ||||
|         useProduction: config.useProduction, | ||||
|         httpsRedirectPort: config.httpsRedirectPort || this.settings.fromPort, | ||||
|         enabled: config.enabled, | ||||
|         autoRenew: config.autoRenew, | ||||
|         certificateStore: config.certificateStore, | ||||
|         skipConfiguredCerts: config.skipConfiguredCerts | ||||
|       }); | ||||
|        | ||||
|       // Register domain forwarding configurations | ||||
|       if (config.domainForwards) { | ||||
|         for (const forward of config.domainForwards) { | ||||
|           this.port80Handler.addDomain({ | ||||
|             domainName: forward.domain, | ||||
|             sslRedirect: true, | ||||
|             acmeMaintenance: true, | ||||
|             forward: forward.forwardConfig, | ||||
|             acmeForward: forward.acmeForwardConfig | ||||
|           }); | ||||
|            | ||||
|           console.log(`Registered domain forwarding for ${forward.domain}`); | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // Provision certificates per domain via certProvider or HTTP-01 | ||||
|       for (const domainConfig of this.settings.domainConfigs) { | ||||
|         for (const domain of domainConfig.domains) { | ||||
|           // Skip wildcard domains | ||||
|           if (domain.includes('*')) continue; | ||||
|           // Determine provisioning method | ||||
|           let provision = 'http01' as string | plugins.tsclass.network.ICert; | ||||
|           if (this.settings.certProvider) { | ||||
|             try { | ||||
|               provision = await this.settings.certProvider(domain); | ||||
|             } catch (err) { | ||||
|               console.log(`certProvider error for ${domain}: ${err}`); | ||||
|             } | ||||
|           } | ||||
|           if (provision === 'http01') { | ||||
|             this.port80Handler.addDomain({ | ||||
|               domainName: domain, | ||||
|               sslRedirect: true, | ||||
|               acmeMaintenance: true | ||||
|             }); | ||||
|             console.log(`Registered domain ${domain} with Port80Handler for HTTP-01`); | ||||
|           } else { | ||||
|             // Static certificate provided | ||||
|             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`); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // Set up event listeners | ||||
|       this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (certData) => { | ||||
|         console.log(`Certificate issued for ${certData.domain}, valid until ${certData.expiryDate.toISOString()}`); | ||||
|         // Re-emit on SmartProxy | ||||
|         this.emit('certificate', { | ||||
|           domain: certData.domain, | ||||
|           publicKey: certData.certificate, | ||||
|           privateKey: certData.privateKey, | ||||
|           expiryDate: certData.expiryDate, | ||||
|           source: 'http01', | ||||
|           isRenewal: false | ||||
|         }); | ||||
|       }); | ||||
|        | ||||
|       this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (certData) => { | ||||
|         console.log(`Certificate renewed for ${certData.domain}, valid until ${certData.expiryDate.toISOString()}`); | ||||
|         // Re-emit on SmartProxy | ||||
|         this.emit('certificate', { | ||||
|           domain: certData.domain, | ||||
|           publicKey: certData.certificate, | ||||
|           privateKey: certData.privateKey, | ||||
|           expiryDate: certData.expiryDate, | ||||
|           source: 'http01', | ||||
|           isRenewal: true | ||||
|         }); | ||||
|       }); | ||||
|        | ||||
|       this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, (failureData) => { | ||||
|         console.log(`Certificate ${failureData.isRenewal ? 'renewal' : 'issuance'} failed for ${failureData.domain}: ${failureData.error}`); | ||||
|       }); | ||||
|        | ||||
|       this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, (expiryData) => { | ||||
|         console.log(`Certificate for ${expiryData.domain} is expiring in ${expiryData.daysRemaining} days`); | ||||
|       }); | ||||
|        | ||||
|       // Share Port80Handler with NetworkProxyBridge | ||||
|       this.networkProxyBridge.setPort80Handler(this.port80Handler); | ||||
| @@ -258,21 +158,6 @@ export class SmartProxy extends plugins.EventEmitter { | ||||
|       // Start Port80Handler | ||||
|       await this.port80Handler.start(); | ||||
|       console.log(`Port80Handler started on port ${config.port}`); | ||||
|       // Schedule certificate renewals using taskbuffer | ||||
|       if (config.autoRenew) { | ||||
|         this.renewManager = new plugins.taskbuffer.TaskManager(); | ||||
|         const renewTask = new plugins.taskbuffer.Task({ | ||||
|           name: 'CertificateRenewals', | ||||
|           taskFunction: async () => { | ||||
|             await (this as any).performRenewals(); | ||||
|           } | ||||
|         }); | ||||
|         const hours = config.renewCheckIntervalHours!; | ||||
|         const cronExpr = `0 0 */${hours} * * *`; | ||||
|         this.renewManager.addAndScheduleTask(renewTask, cronExpr); | ||||
|         this.renewManager.start(); | ||||
|         console.log(`Scheduled certificate renewals every ${hours} hours`); | ||||
|       } | ||||
|     } catch (err) { | ||||
|       console.log(`Error initializing Port80Handler: ${err}`); | ||||
|     } | ||||
| @@ -290,6 +175,37 @@ export class SmartProxy extends plugins.EventEmitter { | ||||
|  | ||||
|     // Initialize Port80Handler if enabled | ||||
|     await this.initializePort80Handler(); | ||||
|     // Initialize CertProvisioner for unified certificate workflows | ||||
|     if (this.port80Handler) { | ||||
|       this.certProvisioner = new CertProvisioner( | ||||
|         this.settings.domainConfigs, | ||||
|         this.port80Handler, | ||||
|         this.networkProxyBridge, | ||||
|         this.settings.certProvider, | ||||
|         this.settings.port80HandlerConfig?.renewThresholdDays || 30, | ||||
|         this.settings.port80HandlerConfig?.renewCheckIntervalHours || 24, | ||||
|         this.settings.port80HandlerConfig?.autoRenew !== false, | ||||
|         // External ACME forwarding for specific domains | ||||
|         this.settings.port80HandlerConfig?.domainForwards?.map(f => ({ | ||||
|           domain: f.domain, | ||||
|           forwardConfig: f.forwardConfig, | ||||
|           acmeForwardConfig: f.acmeForwardConfig, | ||||
|           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 ( | ||||
| @@ -418,10 +334,10 @@ export class SmartProxy extends plugins.EventEmitter { | ||||
|   public async stop() { | ||||
|     console.log('PortProxy shutting down...'); | ||||
|     this.isShuttingDown = true; | ||||
|     // Stop the certificate renewal scheduler if active | ||||
|     if (this.renewManager) { | ||||
|       this.renewManager.stop(); | ||||
|       console.log('Certificate renewal scheduler stopped'); | ||||
|     // Stop CertProvisioner if active | ||||
|     if (this.certProvisioner) { | ||||
|       await this.certProvisioner.stop(); | ||||
|       console.log('CertProvisioner stopped'); | ||||
|     } | ||||
|  | ||||
|     // Stop the Port80Handler if running | ||||
|   | ||||
		Reference in New Issue
	
	Block a user