fix(networkproxy): Refactor certificate management components: rename AcmeCertManager to Port80Handler and update related event names from CertManagerEvents to Port80HandlerEvents. The changes update internal API usage in ts/classes.networkproxy.ts and ts/classes.port80handler.ts to unify and simplify ACME certificate handling and HTTP-01 challenge management.
This commit is contained in:
		| @@ -3,6 +3,6 @@ | ||||
|  */ | ||||
| export const commitinfo = { | ||||
|   name: '@push.rocks/smartproxy', | ||||
|   version: '4.2.4', | ||||
|   version: '4.2.5', | ||||
|   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.' | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import * as plugins from './plugins.js'; | ||||
| import { ProxyRouter } from './classes.router.js'; | ||||
| import { AcmeCertManager, CertManagerEvents } from './classes.port80handler.js'; | ||||
| import { Port80Handler, Port80HandlerEvents, type IDomainOptions } from './classes.port80handler.js'; | ||||
| import * as fs from 'fs'; | ||||
| import * as path from 'path'; | ||||
| import { fileURLToPath } from 'url'; | ||||
| @@ -72,8 +72,8 @@ export class NetworkProxy { | ||||
|   private defaultCertificates: { key: string; cert: string }; | ||||
|   private certificateCache: Map<string, { key: string; cert: string; expires?: Date }> = new Map(); | ||||
|    | ||||
|   // ACME certificate manager | ||||
|   private certManager: AcmeCertManager | null = null; | ||||
|   // Port80Handler for certificate management | ||||
|   private port80Handler: Port80Handler | null = null; | ||||
|   private certificateStoreDir: string; | ||||
|    | ||||
|   // New connection pool for backend connections | ||||
| @@ -375,16 +375,16 @@ export class NetworkProxy { | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Initializes the ACME certificate manager for automatic certificate issuance | ||||
|    * Initializes the Port80Handler for ACME certificate management | ||||
|    * @private | ||||
|    */ | ||||
|   private async initializeAcmeManager(): Promise<void> { | ||||
|   private async initializePort80Handler(): Promise<void> { | ||||
|     if (!this.options.acme.enabled) { | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     // Create certificate manager | ||||
|     this.certManager = new AcmeCertManager({ | ||||
|     this.port80Handler = new Port80Handler({ | ||||
|       port: this.options.acme.port, | ||||
|       contactEmail: this.options.acme.contactEmail, | ||||
|       useProduction: this.options.acme.useProduction, | ||||
| @@ -394,32 +394,32 @@ export class NetworkProxy { | ||||
|     }); | ||||
|      | ||||
|     // Register event handlers | ||||
|     this.certManager.on(CertManagerEvents.CERTIFICATE_ISSUED, this.handleCertificateIssued.bind(this)); | ||||
|     this.certManager.on(CertManagerEvents.CERTIFICATE_RENEWED, this.handleCertificateIssued.bind(this)); | ||||
|     this.certManager.on(CertManagerEvents.CERTIFICATE_FAILED, this.handleCertificateFailed.bind(this)); | ||||
|     this.certManager.on(CertManagerEvents.CERTIFICATE_EXPIRING, (data) => { | ||||
|     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.log('info', `Certificate for ${data.domain} expires in ${data.daysRemaining} days`); | ||||
|     }); | ||||
|      | ||||
|     // Start the manager | ||||
|     // Start the handler | ||||
|     try { | ||||
|       await this.certManager.start(); | ||||
|       this.log('info', `ACME Certificate Manager started on port ${this.options.acme.port}`); | ||||
|       await this.port80Handler.start(); | ||||
|       this.log('info', `Port80Handler started on port ${this.options.acme.port}`); | ||||
|        | ||||
|       // Add domains from proxy configs | ||||
|       this.registerDomainsWithAcmeManager(); | ||||
|       this.registerDomainsWithPort80Handler(); | ||||
|     } catch (error) { | ||||
|       this.log('error', `Failed to start ACME Certificate Manager: ${error}`); | ||||
|       this.certManager = null; | ||||
|       this.log('error', `Failed to start Port80Handler: ${error}`); | ||||
|       this.port80Handler = null; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Registers domains from proxy configs with the ACME manager | ||||
|    * Registers domains from proxy configs with the Port80Handler | ||||
|    * @private | ||||
|    */ | ||||
|   private registerDomainsWithAcmeManager(): void { | ||||
|     if (!this.certManager) return; | ||||
|   private registerDomainsWithPort80Handler(): void { | ||||
|     if (!this.port80Handler) return; | ||||
|      | ||||
|     // Get all hostnames from proxy configs | ||||
|     this.proxyConfigs.forEach(config => { | ||||
| @@ -461,26 +461,32 @@ export class NetworkProxy { | ||||
|             this.log('warn', `Failed to extract expiry date from certificate for ${hostname}`); | ||||
|           } | ||||
|            | ||||
|           // Update the certificate in the manager | ||||
|           this.certManager.setCertificate(hostname, cert, key, expiryDate); | ||||
|           // Update the certificate in the handler | ||||
|           this.port80Handler.setCertificate(hostname, cert, key, expiryDate); | ||||
|            | ||||
|           // Also update our own certificate cache | ||||
|           this.updateCertificateCache(hostname, cert, key, expiryDate); | ||||
|            | ||||
|           this.log('info', `Loaded existing certificate for ${hostname}`); | ||||
|         } else { | ||||
|           // Register the domain for certificate issuance | ||||
|           this.certManager.addDomain(hostname); | ||||
|           // Register the domain for certificate issuance with new domain options format | ||||
|           const domainOptions: IDomainOptions = { | ||||
|             domainName: hostname, | ||||
|             sslRedirect: true, | ||||
|             acmeMaintenance: true | ||||
|           }; | ||||
|            | ||||
|           this.port80Handler.addDomain(domainOptions); | ||||
|           this.log('info', `Registered domain for ACME certificate issuance: ${hostname}`); | ||||
|         } | ||||
|       } catch (error) { | ||||
|         this.log('error', `Error registering domain ${hostname} with ACME manager: ${error}`); | ||||
|         this.log('error', `Error registering domain ${hostname} with Port80Handler: ${error}`); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Handles newly issued or renewed certificates from ACME manager | ||||
|    * Handles newly issued or renewed certificates from Port80Handler | ||||
|    * @private | ||||
|    */ | ||||
|   private handleCertificateIssued(data: { domain: string; certificate: string; privateKey: string; expiryDate: Date }): void { | ||||
| @@ -556,13 +562,21 @@ export class NetworkProxy { | ||||
|     } | ||||
|      | ||||
|     // Check if we should trigger certificate issuance | ||||
|     if (this.options.acme?.enabled && this.certManager && !domain.includes('*')) { | ||||
|     if (this.options.acme?.enabled && this.port80Handler && !domain.includes('*')) { | ||||
|       // Check if this domain is already registered | ||||
|       const certData = this.certManager.getCertificate(domain); | ||||
|       const certData = this.port80Handler.getCertificate(domain); | ||||
|        | ||||
|       if (!certData) { | ||||
|         this.log('info', `No certificate found for ${domain}, registering for issuance`); | ||||
|         this.certManager.addDomain(domain); | ||||
|          | ||||
|         // Register with new domain options format | ||||
|         const domainOptions: IDomainOptions = { | ||||
|           domainName: domain, | ||||
|           sslRedirect: true, | ||||
|           acmeMaintenance: true | ||||
|         }; | ||||
|          | ||||
|         this.port80Handler.addDomain(domainOptions); | ||||
|       } | ||||
|     } | ||||
|      | ||||
| @@ -587,9 +601,9 @@ export class NetworkProxy { | ||||
|   public async start(): Promise<void> { | ||||
|     this.startTime = Date.now(); | ||||
|      | ||||
|     // Initialize ACME certificate manager if enabled | ||||
|     // Initialize Port80Handler if enabled | ||||
|     if (this.options.acme.enabled) { | ||||
|       await this.initializeAcmeManager(); | ||||
|       await this.initializePort80Handler(); | ||||
|     } | ||||
|      | ||||
|     // Create the HTTPS server | ||||
| @@ -1588,13 +1602,13 @@ export class NetworkProxy { | ||||
|     } | ||||
|     this.connectionPool.clear(); | ||||
|      | ||||
|     // Stop ACME certificate manager if it's running | ||||
|     if (this.certManager) { | ||||
|     // Stop Port80Handler if it's running | ||||
|     if (this.port80Handler) { | ||||
|       try { | ||||
|         await this.certManager.stop(); | ||||
|         this.log('info', 'ACME Certificate Manager stopped'); | ||||
|         await this.port80Handler.stop(); | ||||
|         this.log('info', 'Port80Handler stopped'); | ||||
|       } catch (error) { | ||||
|         this.log('error', 'Error stopping ACME Certificate Manager', error); | ||||
|         this.log('error', 'Error stopping Port80Handler', error); | ||||
|       } | ||||
|     } | ||||
|      | ||||
| @@ -1619,8 +1633,8 @@ export class NetworkProxy { | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     if (!this.certManager) { | ||||
|       this.log('error', 'ACME certificate manager is not initialized'); | ||||
|     if (!this.port80Handler) { | ||||
|       this.log('error', 'Port80Handler is not initialized'); | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
| @@ -1631,7 +1645,14 @@ export class NetworkProxy { | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       this.certManager.addDomain(domain); | ||||
|       // Use the new domain options format | ||||
|       const domainOptions: IDomainOptions = { | ||||
|         domainName: domain, | ||||
|         sslRedirect: true, | ||||
|         acmeMaintenance: true | ||||
|       }; | ||||
|        | ||||
|       this.port80Handler.addDomain(domainOptions); | ||||
|       this.log('info', `Certificate request submitted for domain: ${domain}`); | ||||
|       return true; | ||||
|     } catch (error) { | ||||
|   | ||||
| @@ -1,9 +1,58 @@ | ||||
| import * as plugins from './plugins.js'; | ||||
| import { IncomingMessage, ServerResponse } from 'http'; | ||||
|  | ||||
| /** | ||||
|  * Represents a domain certificate with various status information | ||||
|  * Custom error classes for better error handling | ||||
|  */ | ||||
| export class Port80HandlerError extends Error { | ||||
|   constructor(message: string) { | ||||
|     super(message); | ||||
|     this.name = 'Port80HandlerError'; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export class CertificateError extends Port80HandlerError { | ||||
|   constructor( | ||||
|     message: string, | ||||
|     public readonly domain: string, | ||||
|     public readonly isRenewal: boolean = false | ||||
|   ) { | ||||
|     super(`${message} for domain ${domain}${isRenewal ? ' (renewal)' : ''}`); | ||||
|     this.name = 'CertificateError'; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export class ServerError extends Port80HandlerError { | ||||
|   constructor(message: string, public readonly code?: string) { | ||||
|     super(message); | ||||
|     this.name = 'ServerError'; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Domain forwarding configuration | ||||
|  */ | ||||
| export interface IForwardConfig { | ||||
|   ip: string; | ||||
|   port: number; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Domain configuration options | ||||
|  */ | ||||
| export interface IDomainOptions { | ||||
|   domainName: string; | ||||
|   sslRedirect: boolean;   // if true redirects the request to port 443 | ||||
|   acmeMaintenance: boolean; // tries to always have a valid cert for this domain | ||||
|   forward?: IForwardConfig; // forwards all http requests to that target | ||||
|   acmeForward?: IForwardConfig; // forwards letsencrypt requests to this config | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Represents a domain configuration with certificate status information | ||||
|  */ | ||||
| interface IDomainCertificate { | ||||
|   options: IDomainOptions; | ||||
|   certObtained: boolean; | ||||
|   obtainingInProgress: boolean; | ||||
|   certificate?: string; | ||||
| @@ -15,9 +64,9 @@ interface IDomainCertificate { | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Configuration options for the ACME Certificate Manager | ||||
|  * Configuration options for the Port80Handler | ||||
|  */ | ||||
| interface IAcmeCertManagerOptions { | ||||
| interface IPort80HandlerOptions { | ||||
|   port?: number; | ||||
|   contactEmail?: string; | ||||
|   useProduction?: boolean; | ||||
| @@ -29,7 +78,7 @@ interface IAcmeCertManagerOptions { | ||||
| /** | ||||
|  * Certificate data that can be emitted via events or set from outside | ||||
|  */ | ||||
| interface ICertificateData { | ||||
| export interface ICertificateData { | ||||
|   domain: string; | ||||
|   certificate: string; | ||||
|   privateKey: string; | ||||
| @@ -37,34 +86,53 @@ interface ICertificateData { | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Events emitted by the ACME Certificate Manager | ||||
|  * Events emitted by the Port80Handler | ||||
|  */ | ||||
| export enum CertManagerEvents { | ||||
| export enum Port80HandlerEvents { | ||||
|   CERTIFICATE_ISSUED = 'certificate-issued', | ||||
|   CERTIFICATE_RENEWED = 'certificate-renewed', | ||||
|   CERTIFICATE_FAILED = 'certificate-failed', | ||||
|   CERTIFICATE_EXPIRING = 'certificate-expiring', | ||||
|   MANAGER_STARTED = 'manager-started', | ||||
|   MANAGER_STOPPED = 'manager-stopped', | ||||
|   REQUEST_FORWARDED = 'request-forwarded', | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Improved ACME Certificate Manager with event emission and external certificate management | ||||
|  * Certificate failure payload type | ||||
|  */ | ||||
| export class AcmeCertManager extends plugins.EventEmitter { | ||||
| export interface ICertificateFailure { | ||||
|   domain: string; | ||||
|   error: string; | ||||
|   isRenewal: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Certificate expiry payload type | ||||
|  */ | ||||
| export interface ICertificateExpiring { | ||||
|   domain: string; | ||||
|   expiryDate: Date; | ||||
|   daysRemaining: number; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Port80Handler with ACME certificate management and request forwarding capabilities | ||||
|  */ | ||||
| export class Port80Handler extends plugins.EventEmitter { | ||||
|   private domainCertificates: Map<string, IDomainCertificate>; | ||||
|   private server: plugins.http.Server | null = null; | ||||
|   private acmeClient: plugins.acme.Client | null = null; | ||||
|   private accountKey: string | null = null; | ||||
|   private renewalTimer: NodeJS.Timeout | null = null; | ||||
|   private isShuttingDown: boolean = false; | ||||
|   private options: Required<IAcmeCertManagerOptions>; | ||||
|   private options: Required<IPort80HandlerOptions>; | ||||
|  | ||||
|   /** | ||||
|    * Creates a new ACME Certificate Manager | ||||
|    * Creates a new Port80Handler | ||||
|    * @param options Configuration options | ||||
|    */ | ||||
|   constructor(options: IAcmeCertManagerOptions = {}) { | ||||
|   constructor(options: IPort80HandlerOptions = {}) { | ||||
|     super(); | ||||
|     this.domainCertificates = new Map<string, IDomainCertificate>(); | ||||
|      | ||||
| @@ -73,7 +141,7 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|       port: options.port ?? 80, | ||||
|       contactEmail: options.contactEmail ?? 'admin@example.com', | ||||
|       useProduction: options.useProduction ?? false, // Safer default: staging | ||||
|       renewThresholdDays: options.renewThresholdDays ?? 30, | ||||
|       renewThresholdDays: options.renewThresholdDays ?? 10, // Changed to 10 days as per requirements | ||||
|       httpsRedirectPort: options.httpsRedirectPort ?? 443, | ||||
|       renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24, | ||||
|     }; | ||||
| @@ -84,11 +152,11 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|    */ | ||||
|   public async start(): Promise<void> { | ||||
|     if (this.server) { | ||||
|       throw new Error('Server is already running'); | ||||
|       throw new ServerError('Server is already running'); | ||||
|     } | ||||
|      | ||||
|     if (this.isShuttingDown) { | ||||
|       throw new Error('Server is shutting down'); | ||||
|       throw new ServerError('Server is shutting down'); | ||||
|     } | ||||
|  | ||||
|     return new Promise((resolve, reject) => { | ||||
| @@ -97,22 +165,33 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|          | ||||
|         this.server.on('error', (error: NodeJS.ErrnoException) => { | ||||
|           if (error.code === 'EACCES') { | ||||
|             reject(new Error(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`)); | ||||
|             reject(new ServerError(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`, error.code)); | ||||
|           } else if (error.code === 'EADDRINUSE') { | ||||
|             reject(new Error(`Port ${this.options.port} is already in use.`)); | ||||
|             reject(new ServerError(`Port ${this.options.port} is already in use.`, error.code)); | ||||
|           } else { | ||||
|             reject(error); | ||||
|             reject(new ServerError(error.message, error.code)); | ||||
|           } | ||||
|         }); | ||||
|          | ||||
|         this.server.listen(this.options.port, () => { | ||||
|           console.log(`AcmeCertManager is listening on port ${this.options.port}`); | ||||
|           console.log(`Port80Handler is listening on port ${this.options.port}`); | ||||
|           this.startRenewalTimer(); | ||||
|           this.emit(CertManagerEvents.MANAGER_STARTED, this.options.port); | ||||
|           this.emit(Port80HandlerEvents.MANAGER_STARTED, this.options.port); | ||||
|            | ||||
|           // Start certificate process for domains with acmeMaintenance enabled | ||||
|           for (const [domain, domainInfo] of this.domainCertificates.entries()) { | ||||
|             if (domainInfo.options.acmeMaintenance && !domainInfo.certObtained && !domainInfo.obtainingInProgress) { | ||||
|               this.obtainCertificate(domain).catch(err => { | ||||
|                 console.error(`Error obtaining initial certificate for ${domain}:`, err); | ||||
|               }); | ||||
|             } | ||||
|           } | ||||
|            | ||||
|           resolve(); | ||||
|         }); | ||||
|       } catch (error) { | ||||
|         reject(error); | ||||
|         const message = error instanceof Error ? error.message : 'Unknown error starting server'; | ||||
|         reject(new ServerError(message)); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| @@ -138,7 +217,7 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|         this.server.close(() => { | ||||
|           this.server = null; | ||||
|           this.isShuttingDown = false; | ||||
|           this.emit(CertManagerEvents.MANAGER_STOPPED); | ||||
|           this.emit(Port80HandlerEvents.MANAGER_STOPPED); | ||||
|           resolve(); | ||||
|         }); | ||||
|       } else { | ||||
| @@ -149,13 +228,41 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Adds a domain to be managed for certificates | ||||
|    * @param domain The domain to add | ||||
|    * Adds a domain with configuration options | ||||
|    * @param options Domain configuration options | ||||
|    */ | ||||
|   public addDomain(domain: string): void { | ||||
|     if (!this.domainCertificates.has(domain)) { | ||||
|       this.domainCertificates.set(domain, { certObtained: false, obtainingInProgress: false }); | ||||
|       console.log(`Domain added: ${domain}`); | ||||
|   public addDomain(options: IDomainOptions): void { | ||||
|     if (!options.domainName || typeof options.domainName !== 'string') { | ||||
|       throw new Port80HandlerError('Invalid domain name'); | ||||
|     } | ||||
|      | ||||
|     const domainName = options.domainName; | ||||
|      | ||||
|     if (!this.domainCertificates.has(domainName)) { | ||||
|       this.domainCertificates.set(domainName, { | ||||
|         options, | ||||
|         certObtained: false, | ||||
|         obtainingInProgress: false | ||||
|       }); | ||||
|        | ||||
|       console.log(`Domain added: ${domainName} with configuration:`, { | ||||
|         sslRedirect: options.sslRedirect, | ||||
|         acmeMaintenance: options.acmeMaintenance, | ||||
|         hasForward: !!options.forward, | ||||
|         hasAcmeForward: !!options.acmeForward | ||||
|       }); | ||||
|        | ||||
|       // If acmeMaintenance is enabled, start certificate process immediately | ||||
|       if (options.acmeMaintenance && this.server) { | ||||
|         this.obtainCertificate(domainName).catch(err => { | ||||
|           console.error(`Error obtaining initial certificate for ${domainName}:`, err); | ||||
|         }); | ||||
|       } | ||||
|     } else { | ||||
|       // Update existing domain with new options | ||||
|       const existing = this.domainCertificates.get(domainName)!; | ||||
|       existing.options = options; | ||||
|       console.log(`Domain ${domainName} configuration updated`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -177,10 +284,25 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|    * @param expiryDate Optional expiry date | ||||
|    */ | ||||
|   public setCertificate(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void { | ||||
|     if (!domain || !certificate || !privateKey) { | ||||
|       throw new Port80HandlerError('Domain, certificate and privateKey are required'); | ||||
|     } | ||||
|      | ||||
|     let domainInfo = this.domainCertificates.get(domain); | ||||
|      | ||||
|     if (!domainInfo) { | ||||
|       domainInfo = { certObtained: false, obtainingInProgress: false }; | ||||
|       // Create default domain options if not already configured | ||||
|       const defaultOptions: IDomainOptions = { | ||||
|         domainName: domain, | ||||
|         sslRedirect: true, | ||||
|         acmeMaintenance: true | ||||
|       }; | ||||
|        | ||||
|       domainInfo = {  | ||||
|         options: defaultOptions,  | ||||
|         certObtained: false,  | ||||
|         obtainingInProgress: false  | ||||
|       }; | ||||
|       this.domainCertificates.set(domain, domainInfo); | ||||
|     } | ||||
|      | ||||
| @@ -192,27 +314,18 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|     if (expiryDate) { | ||||
|       domainInfo.expiryDate = expiryDate; | ||||
|     } else { | ||||
|       // Try to extract expiry date from certificate | ||||
|       try { | ||||
|         // This is a simplistic approach - in a real implementation, use a proper | ||||
|         // certificate parsing library like node-forge or x509 | ||||
|         const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i); | ||||
|         if (matches && matches[1]) { | ||||
|           domainInfo.expiryDate = new Date(matches[1]); | ||||
|         } | ||||
|       } catch (error) { | ||||
|         console.warn(`Failed to extract expiry date from certificate for ${domain}`); | ||||
|       } | ||||
|       // Extract expiry date from certificate | ||||
|       domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain); | ||||
|     } | ||||
|      | ||||
|     console.log(`Certificate set for ${domain}`); | ||||
|      | ||||
|     // Emit certificate event | ||||
|     this.emitCertificateEvent(CertManagerEvents.CERTIFICATE_ISSUED, { | ||||
|     this.emitCertificateEvent(Port80HandlerEvents.CERTIFICATE_ISSUED, { | ||||
|       domain, | ||||
|       certificate, | ||||
|       privateKey, | ||||
|       expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default | ||||
|       expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate() | ||||
|     }); | ||||
|   } | ||||
|    | ||||
| @@ -231,7 +344,7 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|       domain, | ||||
|       certificate: domainInfo.certificate, | ||||
|       privateKey: domainInfo.privateKey, | ||||
|       expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default | ||||
|       expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate() | ||||
|     }; | ||||
|   } | ||||
|  | ||||
| @@ -244,23 +357,28 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|       return this.acmeClient; | ||||
|     } | ||||
|      | ||||
|     // Generate a new account key | ||||
|     this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString(); | ||||
|      | ||||
|     this.acmeClient = new plugins.acme.Client({ | ||||
|       directoryUrl: this.options.useProduction  | ||||
|         ? plugins.acme.directory.letsencrypt.production  | ||||
|         : plugins.acme.directory.letsencrypt.staging, | ||||
|       accountKey: this.accountKey, | ||||
|     }); | ||||
|      | ||||
|     // Create a new account | ||||
|     await this.acmeClient.createAccount({ | ||||
|       termsOfServiceAgreed: true, | ||||
|       contact: [`mailto:${this.options.contactEmail}`], | ||||
|     }); | ||||
|      | ||||
|     return this.acmeClient; | ||||
|     try { | ||||
|       // Generate a new account key | ||||
|       this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString(); | ||||
|        | ||||
|       this.acmeClient = new plugins.acme.Client({ | ||||
|         directoryUrl: this.options.useProduction  | ||||
|           ? plugins.acme.directory.letsencrypt.production  | ||||
|           : plugins.acme.directory.letsencrypt.staging, | ||||
|         accountKey: this.accountKey, | ||||
|       }); | ||||
|        | ||||
|       // Create a new account | ||||
|       await this.acmeClient.createAccount({ | ||||
|         termsOfServiceAgreed: true, | ||||
|         contact: [`mailto:${this.options.contactEmail}`], | ||||
|       }); | ||||
|        | ||||
|       return this.acmeClient; | ||||
|     } catch (error) { | ||||
|       const message = error instanceof Error ? error.message : 'Unknown error initializing ACME client'; | ||||
|       throw new Port80HandlerError(`Failed to initialize ACME client: ${message}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
| @@ -279,12 +397,7 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|     // Extract domain (ignoring any port in the Host header) | ||||
|     const domain = hostHeader.split(':')[0]; | ||||
|  | ||||
|     // If the request is for an ACME HTTP-01 challenge, handle it | ||||
|     if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) { | ||||
|       this.handleAcmeChallenge(req, res, domain); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Check if domain is configured | ||||
|     if (!this.domainCertificates.has(domain)) { | ||||
|       res.statusCode = 404; | ||||
|       res.end('Domain not configured'); | ||||
| @@ -292,9 +405,28 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|     } | ||||
|  | ||||
|     const domainInfo = this.domainCertificates.get(domain)!; | ||||
|     const options = domainInfo.options; | ||||
|  | ||||
|     // If certificate exists, redirect to HTTPS | ||||
|     if (domainInfo.certObtained) { | ||||
|     // If the request is for an ACME HTTP-01 challenge, handle it | ||||
|     if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) { | ||||
|       // Check if we should forward ACME requests | ||||
|       if (options.acmeForward) { | ||||
|         this.forwardRequest(req, res, options.acmeForward, 'ACME challenge'); | ||||
|         return; | ||||
|       } | ||||
|        | ||||
|       this.handleAcmeChallenge(req, res, domain); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Check if we should forward non-ACME requests | ||||
|     if (options.forward) { | ||||
|       this.forwardRequest(req, res, options.forward, 'HTTP'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // If certificate exists and sslRedirect is enabled, redirect to HTTPS | ||||
|     if (domainInfo.certObtained && options.sslRedirect) { | ||||
|       const httpsPort = this.options.httpsRedirectPort; | ||||
|       const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`; | ||||
|       const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`; | ||||
| @@ -302,17 +434,93 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|       res.statusCode = 301; | ||||
|       res.setHeader('Location', redirectUrl); | ||||
|       res.end(`Redirecting to ${redirectUrl}`); | ||||
|     } else { | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     // Handle case where certificate maintenance is enabled but not yet obtained | ||||
|     if (options.acmeMaintenance && !domainInfo.certObtained) { | ||||
|       // Trigger certificate issuance if not already running | ||||
|       if (!domainInfo.obtainingInProgress) { | ||||
|         this.obtainCertificate(domain).catch(err => { | ||||
|           this.emit(CertManagerEvents.CERTIFICATE_FAILED, { domain, error: err.message }); | ||||
|           const errorMessage = err instanceof Error ? err.message : 'Unknown error'; | ||||
|           this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, { | ||||
|             domain, | ||||
|             error: errorMessage, | ||||
|             isRenewal: false | ||||
|           }); | ||||
|           console.error(`Error obtaining certificate for ${domain}:`, err); | ||||
|         }); | ||||
|       } | ||||
|        | ||||
|       res.statusCode = 503; | ||||
|       res.end('Certificate issuance in progress, please try again later.'); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     // Default response for unhandled request | ||||
|     res.statusCode = 404; | ||||
|     res.end('No handlers configured for this request'); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Forwards an HTTP request to the specified target | ||||
|    * @param req The original request | ||||
|    * @param res The response object | ||||
|    * @param target The forwarding target (IP and port) | ||||
|    * @param requestType Type of request for logging | ||||
|    */ | ||||
|   private forwardRequest( | ||||
|     req: plugins.http.IncomingMessage,  | ||||
|     res: plugins.http.ServerResponse, | ||||
|     target: IForwardConfig, | ||||
|     requestType: string | ||||
|   ): void { | ||||
|     const options = { | ||||
|       hostname: target.ip, | ||||
|       port: target.port, | ||||
|       path: req.url, | ||||
|       method: req.method, | ||||
|       headers: { ...req.headers } | ||||
|     }; | ||||
|      | ||||
|     const domain = req.headers.host?.split(':')[0] || 'unknown'; | ||||
|     console.log(`Forwarding ${requestType} request for ${domain} to ${target.ip}:${target.port}`); | ||||
|      | ||||
|     const proxyReq = plugins.http.request(options, (proxyRes) => { | ||||
|       // Copy status code | ||||
|       res.statusCode = proxyRes.statusCode || 500; | ||||
|        | ||||
|       // Copy headers | ||||
|       for (const [key, value] of Object.entries(proxyRes.headers)) { | ||||
|         if (value) res.setHeader(key, value); | ||||
|       } | ||||
|        | ||||
|       // Pipe response data | ||||
|       proxyRes.pipe(res); | ||||
|        | ||||
|       this.emit(Port80HandlerEvents.REQUEST_FORWARDED, { | ||||
|         domain, | ||||
|         requestType, | ||||
|         target: `${target.ip}:${target.port}`, | ||||
|         statusCode: proxyRes.statusCode | ||||
|       }); | ||||
|     }); | ||||
|      | ||||
|     proxyReq.on('error', (error) => { | ||||
|       console.error(`Error forwarding request to ${target.ip}:${target.port}:`, error); | ||||
|       if (!res.headersSent) { | ||||
|         res.statusCode = 502; | ||||
|         res.end(`Proxy error: ${error.message}`); | ||||
|       } else { | ||||
|         res.end(); | ||||
|       } | ||||
|     }); | ||||
|      | ||||
|     // Pipe original request to proxy request | ||||
|     if (req.readable) { | ||||
|       req.pipe(proxyReq); | ||||
|     } else { | ||||
|       proxyReq.end(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -354,7 +562,13 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|     // Get the domain info | ||||
|     const domainInfo = this.domainCertificates.get(domain); | ||||
|     if (!domainInfo) { | ||||
|       throw new Error(`Domain not found: ${domain}`); | ||||
|       throw new CertificateError('Domain not found', domain, isRenewal); | ||||
|     } | ||||
|      | ||||
|     // Verify that acmeMaintenance is enabled | ||||
|     if (!domainInfo.options.acmeMaintenance) { | ||||
|       console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     // Prevent concurrent certificate issuance | ||||
| @@ -377,40 +591,8 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|       // Get the authorizations for the order | ||||
|       const authorizations = await client.getAuthorizations(order); | ||||
|        | ||||
|       for (const authz of authorizations) { | ||||
|         const challenge = authz.challenges.find(ch => ch.type === 'http-01'); | ||||
|         if (!challenge) { | ||||
|           throw new Error('HTTP-01 challenge not found'); | ||||
|         } | ||||
|          | ||||
|         // Get the key authorization for the challenge | ||||
|         const keyAuthorization = await client.getChallengeKeyAuthorization(challenge); | ||||
|          | ||||
|         // Store the challenge data | ||||
|         domainInfo.challengeToken = challenge.token; | ||||
|         domainInfo.challengeKeyAuthorization = keyAuthorization; | ||||
|  | ||||
|         // ACME client type definition workaround - use compatible approach | ||||
|         // First check if challenge verification is needed | ||||
|         const authzUrl = authz.url; | ||||
|          | ||||
|         try { | ||||
|           // Check if authzUrl exists and perform verification | ||||
|           if (authzUrl) { | ||||
|             await client.verifyChallenge(authz, challenge); | ||||
|           } | ||||
|            | ||||
|           // Complete the challenge | ||||
|           await client.completeChallenge(challenge); | ||||
|            | ||||
|           // Wait for validation | ||||
|           await client.waitForValidStatus(challenge); | ||||
|           console.log(`HTTP-01 challenge completed for ${domain}`); | ||||
|         } catch (error) { | ||||
|           console.error(`Challenge error for ${domain}:`, error); | ||||
|           throw error; | ||||
|         } | ||||
|       } | ||||
|       // Process each authorization | ||||
|       await this.processAuthorizations(client, domain, authorizations); | ||||
|  | ||||
|       // Generate a CSR and private key | ||||
|       const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({ | ||||
| @@ -436,28 +618,20 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|       delete domainInfo.challengeKeyAuthorization; | ||||
|        | ||||
|       // Extract expiry date from certificate | ||||
|       try { | ||||
|         const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i); | ||||
|         if (matches && matches[1]) { | ||||
|           domainInfo.expiryDate = new Date(matches[1]); | ||||
|           console.log(`Certificate for ${domain} will expire on ${domainInfo.expiryDate.toISOString()}`); | ||||
|         } | ||||
|       } catch (error) { | ||||
|         console.warn(`Failed to extract expiry date from certificate for ${domain}`); | ||||
|       } | ||||
|       domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain); | ||||
|  | ||||
|       console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`); | ||||
|        | ||||
|       // Emit the appropriate event | ||||
|       const eventType = isRenewal  | ||||
|         ? CertManagerEvents.CERTIFICATE_RENEWED  | ||||
|         : CertManagerEvents.CERTIFICATE_ISSUED; | ||||
|         ? Port80HandlerEvents.CERTIFICATE_RENEWED  | ||||
|         : Port80HandlerEvents.CERTIFICATE_ISSUED; | ||||
|        | ||||
|       this.emitCertificateEvent(eventType, { | ||||
|         domain, | ||||
|         certificate, | ||||
|         privateKey, | ||||
|         expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default | ||||
|         expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate() | ||||
|       }); | ||||
|        | ||||
|     } catch (error: any) { | ||||
| @@ -473,17 +647,76 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|       } | ||||
|        | ||||
|       // Emit failure event | ||||
|       this.emit(CertManagerEvents.CERTIFICATE_FAILED, { | ||||
|       this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, { | ||||
|         domain, | ||||
|         error: error.message || 'Unknown error', | ||||
|         isRenewal | ||||
|       }); | ||||
|       } as ICertificateFailure); | ||||
|        | ||||
|       throw new CertificateError( | ||||
|         error.message || 'Certificate issuance failed', | ||||
|         domain, | ||||
|         isRenewal | ||||
|       ); | ||||
|     } finally { | ||||
|       // Reset flag whether successful or not | ||||
|       domainInfo.obtainingInProgress = false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Process ACME authorizations by verifying and completing challenges | ||||
|    * @param client ACME client  | ||||
|    * @param domain Domain name | ||||
|    * @param authorizations Authorizations to process | ||||
|    */ | ||||
|   private async processAuthorizations( | ||||
|     client: plugins.acme.Client, | ||||
|     domain: string, | ||||
|     authorizations: plugins.acme.Authorization[] | ||||
|   ): Promise<void> { | ||||
|     const domainInfo = this.domainCertificates.get(domain); | ||||
|     if (!domainInfo) { | ||||
|       throw new CertificateError('Domain not found during authorization', domain); | ||||
|     } | ||||
|      | ||||
|     for (const authz of authorizations) { | ||||
|       const challenge = authz.challenges.find(ch => ch.type === 'http-01'); | ||||
|       if (!challenge) { | ||||
|         throw new CertificateError('HTTP-01 challenge not found', domain); | ||||
|       } | ||||
|        | ||||
|       // Get the key authorization for the challenge | ||||
|       const keyAuthorization = await client.getChallengeKeyAuthorization(challenge); | ||||
|        | ||||
|       // Store the challenge data | ||||
|       domainInfo.challengeToken = challenge.token; | ||||
|       domainInfo.challengeKeyAuthorization = keyAuthorization; | ||||
|  | ||||
|       // ACME client type definition workaround - use compatible approach | ||||
|       // First check if challenge verification is needed | ||||
|       const authzUrl = authz.url; | ||||
|        | ||||
|       try { | ||||
|         // Check if authzUrl exists and perform verification | ||||
|         if (authzUrl) { | ||||
|           await client.verifyChallenge(authz, challenge); | ||||
|         } | ||||
|          | ||||
|         // Complete the challenge | ||||
|         await client.completeChallenge(challenge); | ||||
|          | ||||
|         // Wait for validation | ||||
|         await client.waitForValidStatus(challenge); | ||||
|         console.log(`HTTP-01 challenge completed for ${domain}`); | ||||
|       } catch (error) { | ||||
|         const errorMessage = error instanceof Error ? error.message : 'Unknown challenge error'; | ||||
|         console.error(`Challenge error for ${domain}:`, error); | ||||
|         throw new CertificateError(`Challenge verification failed: ${errorMessage}`, domain); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Starts the certificate renewal timer | ||||
|    */ | ||||
| @@ -519,6 +752,11 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|     const renewThresholdMs = this.options.renewThresholdDays * 24 * 60 * 60 * 1000; | ||||
|      | ||||
|     for (const [domain, domainInfo] of this.domainCertificates.entries()) { | ||||
|       // Skip domains with acmeMaintenance disabled | ||||
|       if (!domainInfo.options.acmeMaintenance) { | ||||
|         continue; | ||||
|       } | ||||
|        | ||||
|       // Skip domains without certificates or already in renewal | ||||
|       if (!domainInfo.certObtained || domainInfo.obtainingInProgress) { | ||||
|         continue; | ||||
| @@ -534,26 +772,67 @@ export class AcmeCertManager extends plugins.EventEmitter { | ||||
|       // Check if certificate is near expiry | ||||
|       if (timeUntilExpiry <= renewThresholdMs) { | ||||
|         console.log(`Certificate for ${domain} expires soon, renewing...`); | ||||
|         this.emit(CertManagerEvents.CERTIFICATE_EXPIRING, { | ||||
|          | ||||
|         const daysRemaining = Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000)); | ||||
|          | ||||
|         this.emit(Port80HandlerEvents.CERTIFICATE_EXPIRING, { | ||||
|           domain, | ||||
|           expiryDate: domainInfo.expiryDate, | ||||
|           daysRemaining: Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000)) | ||||
|         }); | ||||
|           daysRemaining | ||||
|         } as ICertificateExpiring); | ||||
|          | ||||
|         // Start renewal process | ||||
|         this.obtainCertificate(domain, true).catch(err => { | ||||
|           console.error(`Error renewing certificate for ${domain}:`, err); | ||||
|           const errorMessage = err instanceof Error ? err.message : 'Unknown error'; | ||||
|           console.error(`Error renewing certificate for ${domain}:`, errorMessage); | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Extract expiry date from certificate using a more robust approach | ||||
|    * @param certificate Certificate PEM string | ||||
|    * @param domain Domain for logging | ||||
|    * @returns Extracted expiry date or default | ||||
|    */ | ||||
|   private extractExpiryDateFromCertificate(certificate: string, domain: string): Date { | ||||
|     try { | ||||
|       // This is still using regex, but in a real implementation you would use | ||||
|       // a library like node-forge or x509 to properly parse the certificate | ||||
|       const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i); | ||||
|       if (matches && matches[1]) { | ||||
|         const expiryDate = new Date(matches[1]); | ||||
|          | ||||
|         // Validate that we got a valid date | ||||
|         if (!isNaN(expiryDate.getTime())) { | ||||
|           console.log(`Certificate for ${domain} will expire on ${expiryDate.toISOString()}`); | ||||
|           return expiryDate; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       console.warn(`Could not extract valid expiry date from certificate for ${domain}, using default`); | ||||
|       return this.getDefaultExpiryDate(); | ||||
|     } catch (error) { | ||||
|       console.warn(`Failed to extract expiry date from certificate for ${domain}, using default`); | ||||
|       return this.getDefaultExpiryDate(); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Get a default expiry date (90 days from now) | ||||
|    * @returns Default expiry date | ||||
|    */ | ||||
|   private getDefaultExpiryDate(): Date { | ||||
|     return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); // 90 days default | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Emits a certificate event with the certificate data | ||||
|    * @param eventType The event type to emit | ||||
|    * @param data The certificate data | ||||
|    */ | ||||
|   private emitCertificateEvent(eventType: CertManagerEvents, data: ICertificateData): void { | ||||
|   private emitCertificateEvent(eventType: Port80HandlerEvents, data: ICertificateData): void { | ||||
|     this.emit(eventType, data); | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user