import * as plugins from '../../plugins.js'; import { IncomingMessage, ServerResponse } from 'http'; import { CertificateEvents } from '../../certificate/events/certificate-events.js'; import type { CertificateData, CertificateFailure, CertificateExpiring } from '../../certificate/models/certificate-types.js'; import type { SmartAcme, SmartAcmeCert, SmartAcmeOptions, Http01MemoryHandler } from './acme-interfaces.js'; /** * ChallengeResponder handles ACME HTTP-01 challenges by leveraging SmartAcme * It acts as a bridge between the HTTP server and the ACME challenge verification process */ export class ChallengeResponder extends plugins.EventEmitter { private smartAcme: SmartAcme | null = null; private http01Handler: Http01MemoryHandler | null = null; /** * Creates a new challenge responder * @param useProduction Whether to use production ACME servers * @param email Account email for ACME * @param certificateStore Directory to store certificates */ constructor( private readonly useProduction: boolean = false, private readonly email: string = 'admin@example.com', private readonly certificateStore: string = './certs' ) { super(); } /** * Initialize the ACME client */ public async initialize(): Promise { try { // Create the HTTP-01 memory handler from SmartACME this.http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler(); // Ensure certificate store directory exists await this.ensureCertificateStore(); // Create a MemoryCertManager for certificate storage const certManager = new plugins.smartacme.certmanagers.MemoryCertManager(); // Initialize the SmartACME client with appropriate options this.smartAcme = new plugins.smartacme.SmartAcme({ accountEmail: this.email, certManager: certManager, environment: this.useProduction ? 'production' : 'integration', challengeHandlers: [this.http01Handler], challengePriority: ['http-01'] }); // Set up event forwarding from SmartAcme this.setupEventListeners(); // Start the SmartACME client await this.smartAcme.start(); console.log('ACME client initialized successfully'); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to initialize ACME client: ${errorMessage}`); } } /** * Ensure the certificate store directory exists */ private async ensureCertificateStore(): Promise { try { await plugins.fs.promises.mkdir(this.certificateStore, { recursive: true }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to create certificate store: ${errorMessage}`); } } /** * Setup event listeners to forward SmartACME events to our own event emitter */ private setupEventListeners(): void { if (!this.smartAcme) return; const setupEvents = (emitter: { on: (event: string, listener: (data: any) => void) => void }) => { // Forward certificate events emitter.on('certificate', (data: any) => { const isRenewal = !!data.isRenewal; const certData: CertificateData = { domain: data.domainName || data.domain, certificate: data.publicKey || data.cert, privateKey: data.privateKey || data.key, expiryDate: new Date(data.validUntil || data.expiryDate || Date.now()), source: 'http01', isRenewal }; const eventType = isRenewal ? CertificateEvents.CERTIFICATE_RENEWED : CertificateEvents.CERTIFICATE_ISSUED; this.emit(eventType, certData); }); // Forward error events emitter.on('error', (error: any) => { const domain = error.domainName || error.domain || 'unknown'; const failureData: CertificateFailure = { domain, error: error.message || String(error), isRenewal: !!error.isRenewal }; this.emit(CertificateEvents.CERTIFICATE_FAILED, failureData); }); }; // Check for direct event methods on SmartAcme if (typeof this.smartAcme.on === 'function') { setupEvents(this.smartAcme as any); } // Check for eventEmitter property else if (this.smartAcme.eventEmitter) { setupEvents(this.smartAcme.eventEmitter); } // If no proper event handling, log a warning else { console.warn('SmartAcme instance does not support expected event interface - events may not be forwarded'); } } /** * Handle HTTP request by checking if it's an ACME challenge * @param req HTTP request object * @param res HTTP response object * @returns true if the request was handled, false otherwise */ public handleRequest(req: IncomingMessage, res: ServerResponse): boolean { if (!this.http01Handler) return false; // Check if this is an ACME challenge request (/.well-known/acme-challenge/*) const url = req.url || ''; if (url.startsWith('/.well-known/acme-challenge/')) { try { // Delegate to the HTTP-01 memory handler, which knows how to serve challenges this.http01Handler.handleRequest(req, res); return true; } catch (error) { console.error('Error handling ACME challenge:', error); // If there was an error, send a 404 response res.writeHead(404); res.end('Not found'); return true; } } return false; } /** * Request a certificate for a domain * @param domain Domain name to request a certificate for * @param isRenewal Whether this is a renewal request */ public async requestCertificate(domain: string, isRenewal: boolean = false): Promise { if (!this.smartAcme) { throw new Error('ACME client not initialized'); } try { // Request certificate using SmartACME const certObj = await this.smartAcme.getCertificateForDomain(domain); // Convert the certificate object to our CertificateData format const certData: CertificateData = { domain, certificate: certObj.publicKey, privateKey: certObj.privateKey, expiryDate: new Date(certObj.validUntil), source: 'http01', isRenewal }; return certData; } catch (error) { // Create failure object const failure: CertificateFailure = { domain, error: error instanceof Error ? error.message : String(error), isRenewal }; // Emit failure event this.emit(CertificateEvents.CERTIFICATE_FAILED, failure); // Rethrow with more context throw new Error(`Failed to ${isRenewal ? 'renew' : 'obtain'} certificate for ${domain}: ${ error instanceof Error ? error.message : String(error) }`); } } /** * Check if a certificate is expiring soon and trigger renewal if needed * @param domain Domain name * @param certificate Certificate data * @param thresholdDays Days before expiry to trigger renewal */ public checkCertificateExpiry( domain: string, certificate: CertificateData, thresholdDays: number = 30 ): void { if (!certificate.expiryDate) return; const now = new Date(); const expiryDate = certificate.expiryDate; const daysDifference = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); if (daysDifference <= thresholdDays) { const expiryInfo: CertificateExpiring = { domain, expiryDate, daysRemaining: daysDifference }; this.emit(CertificateEvents.CERTIFICATE_EXPIRING, expiryInfo); // Automatically attempt renewal if expiring if (this.smartAcme) { this.requestCertificate(domain, true).catch(error => { console.error(`Failed to auto-renew certificate for ${domain}:`, error); }); } } } }