feat(ACME/Certificate): Introduce certificate provider hook and observable certificate events; remove legacy ACME flow
This commit is contained in:
		| @@ -3,6 +3,6 @@ | ||||
|  */ | ||||
| export const commitinfo = { | ||||
|   name: '@push.rocks/smartproxy', | ||||
|   version: '7.1.4', | ||||
|   version: '7.2.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.' | ||||
| } | ||||
|   | ||||
| @@ -74,8 +74,6 @@ interface IDomainCertificate { | ||||
|   obtainingInProgress: boolean; | ||||
|   certificate?: string; | ||||
|   privateKey?: string; | ||||
|   challengeToken?: string; | ||||
|   challengeKeyAuthorization?: string; | ||||
|   expiryDate?: Date; | ||||
|   lastRenewalAttempt?: Date; | ||||
| } | ||||
| @@ -94,7 +92,6 @@ interface IPort80HandlerOptions { | ||||
|   autoRenew?: boolean; // Whether to automatically renew certificates | ||||
|   certificateStore?: string; // Directory to store certificates | ||||
|   skipConfiguredCerts?: boolean; // Skip domains that already have certificates | ||||
|   mongoDescriptor?: plugins.smartdata.IMongoDescriptor; // MongoDB config for SmartAcme | ||||
| } | ||||
|  | ||||
| /** | ||||
| @@ -149,8 +146,6 @@ export class Port80Handler extends plugins.EventEmitter { | ||||
|   // SmartAcme instance for certificate management | ||||
|   private smartAcme: plugins.smartacme.SmartAcme | null = null; | ||||
|   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<IPort80HandlerOptions>; | ||||
| @@ -197,13 +192,10 @@ export class Port80Handler extends plugins.EventEmitter { | ||||
|     } | ||||
|     // Initialize SmartAcme for ACME challenge management (diskless HTTP handler) | ||||
|     if (this.options.enabled) { | ||||
|       if (!this.options.mongoDescriptor) { | ||||
|         throw new ServerError('MongoDB descriptor is required for SmartAcme'); | ||||
|       } | ||||
|       this.smartAcme = new plugins.smartacme.SmartAcme({ | ||||
|         accountEmail: this.options.contactEmail, | ||||
|         certManager: new plugins.smartacme.MemoryCertManager(), | ||||
|         environment: this.options.useProduction ? 'production' : 'integration', | ||||
|         mongoDescriptor: this.options.mongoDescriptor, | ||||
|         challengeHandlers: [ new DisklessHttp01Handler(this.acmeHttp01Storage) ], | ||||
|         challengePriority: ['http-01'], | ||||
|       }); | ||||
| @@ -613,38 +605,6 @@ export class Port80Handler extends plugins.EventEmitter { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Lazy initialization of the ACME client | ||||
|    * @returns An ACME client instance | ||||
|    */ | ||||
|   private async getAcmeClient(): Promise<plugins.acme.Client> { | ||||
|     if (this.acmeClient) { | ||||
|       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}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Handles incoming HTTP requests | ||||
| @@ -803,209 +763,73 @@ export class Port80Handler extends plugins.EventEmitter { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Serves the ACME HTTP-01 challenge response | ||||
|    * @param req The HTTP request | ||||
|    * @param res The HTTP response | ||||
|    * @param domain The domain for the challenge | ||||
|    */ | ||||
|   private handleAcmeChallenge(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse, domain: string): void { | ||||
|     const domainInfo = this.domainCertificates.get(domain); | ||||
|     if (!domainInfo) { | ||||
|       res.statusCode = 404; | ||||
|       res.end('Domain not configured'); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     // The token is the last part of the URL | ||||
|     const urlParts = req.url?.split('/'); | ||||
|     const token = urlParts ? urlParts[urlParts.length - 1] : ''; | ||||
|      | ||||
|     if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) { | ||||
|       res.statusCode = 200; | ||||
|       res.setHeader('Content-Type', 'text/plain'); | ||||
|       res.end(domainInfo.challengeKeyAuthorization); | ||||
|       console.log(`Served ACME challenge response for ${domain}`); | ||||
|     } else { | ||||
|       res.statusCode = 404; | ||||
|       res.end('Challenge token not found'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Obtains a certificate for a domain using ACME HTTP-01 challenge | ||||
|    * @param domain The domain to obtain a certificate for | ||||
|    * @param isRenewal Whether this is a renewal attempt | ||||
|    */ | ||||
|   /** | ||||
|    * Obtains a certificate for a domain using SmartAcme HTTP-01 challenges | ||||
|    * @param domain The domain to obtain a certificate for | ||||
|    * @param isRenewal Whether this is a renewal attempt | ||||
|    */ | ||||
|   private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> { | ||||
|     // Don't allow certificate issuance for glob patterns | ||||
|     if (this.isGlobPattern(domain)) { | ||||
|       throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal); | ||||
|     } | ||||
|      | ||||
|     // Get the domain info | ||||
|     const domainInfo = this.domainCertificates.get(domain); | ||||
|     if (!domainInfo) { | ||||
|       throw new CertificateError('Domain not found', domain, isRenewal); | ||||
|     } | ||||
|      | ||||
|     // Verify that acmeMaintenance is enabled | ||||
|     const domainInfo = this.domainCertificates.get(domain)!; | ||||
|     if (!domainInfo.options.acmeMaintenance) { | ||||
|       console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     // Prevent concurrent certificate issuance | ||||
|     if (domainInfo.obtainingInProgress) { | ||||
|       console.log(`Certificate issuance already in progress for ${domain}`); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     if (!this.smartAcme) { | ||||
|       throw new Port80HandlerError('SmartAcme is not initialized'); | ||||
|     } | ||||
|     domainInfo.obtainingInProgress = true; | ||||
|     domainInfo.lastRenewalAttempt = new Date(); | ||||
|      | ||||
|     try { | ||||
|       const client = await this.getAcmeClient(); | ||||
|  | ||||
|       // Create a new order for the domain | ||||
|       const order = await client.createOrder({ | ||||
|         identifiers: [{ type: 'dns', value: domain }], | ||||
|       }); | ||||
|  | ||||
|       // Get the authorizations for the order | ||||
|       const authorizations = await client.getAuthorizations(order); | ||||
|        | ||||
|       // Process each authorization | ||||
|       await this.processAuthorizations(client, domain, authorizations); | ||||
|  | ||||
|       // Generate a CSR and private key | ||||
|       const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({ | ||||
|         commonName: domain, | ||||
|       }); | ||||
|        | ||||
|       const csr = csrBuffer.toString(); | ||||
|       const privateKey = privateKeyBuffer.toString(); | ||||
|  | ||||
|       // Finalize the order with our CSR | ||||
|       await client.finalizeOrder(order, csr); | ||||
|        | ||||
|       // Get the certificate with the full chain | ||||
|       const certificate = await client.getCertificate(order); | ||||
|  | ||||
|       // Store the certificate and key | ||||
|       // Request certificate via SmartAcme | ||||
|       const certObj = await this.smartAcme.getCertificateForDomain(domain); | ||||
|       const certificate = certObj.publicKey; | ||||
|       const privateKey = certObj.privateKey; | ||||
|       const expiryDate = new Date(certObj.validUntil); | ||||
|       domainInfo.certificate = certificate; | ||||
|       domainInfo.privateKey = privateKey; | ||||
|       domainInfo.certObtained = true; | ||||
|        | ||||
|       // Clear challenge data | ||||
|       delete domainInfo.challengeToken; | ||||
|       delete domainInfo.challengeKeyAuthorization; | ||||
|        | ||||
|       // Extract expiry date from certificate | ||||
|       domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain); | ||||
|       domainInfo.expiryDate = expiryDate; | ||||
|  | ||||
|       console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`); | ||||
|        | ||||
|       // Save the certificate to the store if enabled | ||||
|       if (this.options.certificateStore) { | ||||
|         this.saveCertificateToStore(domain, certificate, privateKey); | ||||
|       } | ||||
|        | ||||
|       // Emit the appropriate event | ||||
|       const eventType = isRenewal  | ||||
|         ? Port80HandlerEvents.CERTIFICATE_RENEWED  | ||||
|       const eventType = isRenewal | ||||
|         ? Port80HandlerEvents.CERTIFICATE_RENEWED | ||||
|         : Port80HandlerEvents.CERTIFICATE_ISSUED; | ||||
|        | ||||
|       this.emitCertificateEvent(eventType, { | ||||
|         domain, | ||||
|         certificate, | ||||
|         privateKey, | ||||
|         expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate() | ||||
|         expiryDate: expiryDate || this.getDefaultExpiryDate() | ||||
|       }); | ||||
|        | ||||
|     } catch (error: any) { | ||||
|       // Check for rate limit errors | ||||
|       if (error.message && ( | ||||
|         error.message.includes('rateLimited') ||  | ||||
|         error.message.includes('too many certificates') ||  | ||||
|         error.message.includes('rate limit') | ||||
|       )) { | ||||
|         console.error(`Rate limit reached for ${domain}. Waiting before retry.`); | ||||
|       } else { | ||||
|         console.error(`Error during certificate issuance for ${domain}:`, error); | ||||
|       } | ||||
|        | ||||
|       // Emit failure event | ||||
|       const errorMsg = error?.message || 'Unknown error'; | ||||
|       console.error(`Error during certificate issuance for ${domain}:`, error); | ||||
|       this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, { | ||||
|         domain, | ||||
|         error: error.message || 'Unknown error', | ||||
|         error: errorMsg, | ||||
|         isRenewal | ||||
|       } as ICertificateFailure); | ||||
|        | ||||
|       throw new CertificateError( | ||||
|         error.message || 'Certificate issuance failed', | ||||
|         domain, | ||||
|         isRenewal | ||||
|       ); | ||||
|       throw new CertificateError(errorMsg, 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 | ||||
|    */ | ||||
|   | ||||
| @@ -1,5 +1,10 @@ | ||||
| import * as plugins from '../plugins.js'; | ||||
|  | ||||
| /** | ||||
|  * Provision object for static or HTTP-01 certificate | ||||
|  */ | ||||
| export type ISmartProxyCertProvisionObject = plugins.tsclass.network.ICert | 'http01'; | ||||
|  | ||||
| /** Domain configuration with per-domain allowed port ranges */ | ||||
| export interface IDomainConfig { | ||||
|   domains: string[]; // Glob patterns for domain(s) | ||||
| @@ -115,6 +120,11 @@ export interface IPortProxySettings { | ||||
|     certificateStore?: string; | ||||
|     skipConfiguredCerts?: boolean; | ||||
|   }; | ||||
|   /** | ||||
|    * Optional certificate provider callback. Return 'http01' to use HTTP-01 challenges, | ||||
|    * or a static certificate object for immediate provisioning. | ||||
|    */ | ||||
|   certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>; | ||||
| } | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -95,6 +95,17 @@ export class NetworkProxyBridge { | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Apply an external (static) certificate into NetworkProxy | ||||
|    */ | ||||
|   public applyExternalCertificate(data: ICertificateData): void { | ||||
|     if (!this.networkProxy) { | ||||
|       console.log(`NetworkProxy not initialized: cannot apply external certificate for ${data.domain}`); | ||||
|       return; | ||||
|     } | ||||
|     this.handleCertificateEvent(data); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Get the NetworkProxy instance | ||||
|    */ | ||||
|   | ||||
| @@ -8,14 +8,14 @@ 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 } from '../port80handler/classes.port80handler.js'; | ||||
| import { Port80Handler, Port80HandlerEvents, type ICertificateData } from '../port80handler/classes.port80handler.js'; | ||||
| import * as path from 'path'; | ||||
| import * as fs from 'fs'; | ||||
|  | ||||
| /** | ||||
|  * SmartProxy - Main class that coordinates all components | ||||
|  */ | ||||
| export class SmartProxy { | ||||
| export class SmartProxy extends plugins.EventEmitter { | ||||
|   private netServers: plugins.net.Server[] = []; | ||||
|   private connectionLogger: NodeJS.Timeout | null = null; | ||||
|   private isShuttingDown: boolean = false; | ||||
| @@ -34,6 +34,7 @@ export class SmartProxy { | ||||
|   private port80Handler: Port80Handler | null = null; | ||||
|    | ||||
|   constructor(settingsArg: IPortProxySettings) { | ||||
|     super(); | ||||
|     // Set reasonable defaults for all settings | ||||
|     this.settings = { | ||||
|       ...settingsArg, | ||||
| @@ -180,29 +181,67 @@ export class SmartProxy { | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // Register all non-wildcard domains from domain configs | ||||
|       // Provision certificates per domain via certProvider or HTTP-01 | ||||
|       for (const domainConfig of this.settings.domainConfigs) { | ||||
|         for (const domain of domainConfig.domains) { | ||||
|           // Skip wildcards | ||||
|           // Skip wildcard domains | ||||
|           if (domain.includes('*')) continue; | ||||
|            | ||||
|           this.port80Handler.addDomain({ | ||||
|             domainName: domain, | ||||
|             sslRedirect: true, | ||||
|             acmeMaintenance: true | ||||
|           }); | ||||
|            | ||||
|           console.log(`Registered domain ${domain} with Port80Handler`); | ||||
|           // 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) => { | ||||
| @@ -429,22 +468,40 @@ export class SmartProxy { | ||||
|       await this.networkProxyBridge.syncDomainConfigsToNetworkProxy(); | ||||
|     } | ||||
|      | ||||
|     // If Port80Handler is running, register non-wildcard domains | ||||
|     // If Port80Handler is running, provision certificates per new domain | ||||
|     if (this.port80Handler && this.settings.port80HandlerConfig?.enabled) { | ||||
|       for (const domainConfig of newDomainConfigs) { | ||||
|         for (const domain of domainConfig.domains) { | ||||
|           // Skip wildcards | ||||
|           if (domain.includes('*')) continue; | ||||
|            | ||||
|           this.port80Handler.addDomain({ | ||||
|             domainName: domain, | ||||
|             sslRedirect: true, | ||||
|             acmeMaintenance: true | ||||
|           }); | ||||
|           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 { | ||||
|             const certObj = provision as plugins.tsclass.network.ICert; | ||||
|             const certData: ICertificateData = { | ||||
|               domain: certObj.domainName, | ||||
|               certificate: certObj.publicKey, | ||||
|               privateKey: certObj.privateKey, | ||||
|               expiryDate: new Date(certObj.validUntil) | ||||
|             }; | ||||
|             this.networkProxyBridge.applyExternalCertificate(certData); | ||||
|             console.log(`Applied static certificate for ${domain} from certProvider`); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       console.log('Registered non-wildcard domains with Port80Handler'); | ||||
|       console.log('Provisioned certificates for new domains'); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   | ||||
		Reference in New Issue
	
	Block a user