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'; /** * Handles ACME HTTP-01 challenge responses */ export class ChallengeResponder extends plugins.EventEmitter { private smartAcme: plugins.smartacme.SmartAcme | null = null; private http01Handler: plugins.smartacme.handlers.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 { // Initialize HTTP-01 challenge handler this.http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler(); // Initialize SmartAcme with proper options this.smartAcme = new plugins.smartacme.SmartAcme({ accountEmail: this.email, certManager: new plugins.smartacme.certmanagers.MemoryCertManager(), environment: this.useProduction ? 'production' : 'integration', challengeHandlers: [this.http01Handler], challengePriority: ['http-01'], }); // Ensure certificate store directory exists await this.ensureCertificateStore(); // Set up event forwarding from SmartAcme this.setupEventForwarding(); // Start SmartAcme await this.smartAcme.start(); } catch (error) { throw new Error(`Failed to initialize ACME client: ${error instanceof Error ? error.message : String(error)}`); } } /** * Sets up event forwarding from SmartAcme to this component */ private setupEventForwarding(): void { if (!this.smartAcme) return; // Cast smartAcme to any since different versions have different event APIs const smartAcmeAny = this.smartAcme as any; // Forward certificate events to our own emitter if (typeof smartAcmeAny.on === 'function') { smartAcmeAny.on('certificate', (data: any) => { const certData: CertificateData = { domain: data.domain, certificate: data.cert || data.publicKey, privateKey: data.key || data.privateKey, expiryDate: new Date(data.expiryDate || data.validUntil), source: 'http01' }; // Emit as issued or renewed based on the renewal flag const eventType = data.isRenewal ? CertificateEvents.CERTIFICATE_RENEWED : CertificateEvents.CERTIFICATE_ISSUED; this.emit(eventType, certData); }); smartAcmeAny.on('error', (data: any) => { const failure: CertificateFailure = { domain: data.domain || 'unknown', error: data.message || data.toString(), isRenewal: false }; this.emit(CertificateEvents.CERTIFICATE_FAILED, failure); }); } else if (smartAcmeAny.eventEmitter && typeof smartAcmeAny.eventEmitter.on === 'function') { // Alternative event emitter approach for newer versions smartAcmeAny.eventEmitter.on('certificate', (data: any) => { const certData: CertificateData = { domain: data.domain, certificate: data.cert || data.publicKey, privateKey: data.key || data.privateKey, expiryDate: new Date(data.expiryDate || data.validUntil), source: 'http01' }; const eventType = data.isRenewal ? CertificateEvents.CERTIFICATE_RENEWED : CertificateEvents.CERTIFICATE_ISSUED; this.emit(eventType, certData); }); smartAcmeAny.eventEmitter.on('error', (data: any) => { const failure: CertificateFailure = { domain: data.domain || 'unknown', error: data.message || data.toString(), isRenewal: false }; this.emit(CertificateEvents.CERTIFICATE_FAILED, failure); }); } } /** * Ensure certificate store directory exists */ private async ensureCertificateStore(): Promise { try { await plugins.fs.promises.mkdir(this.certificateStore, { recursive: true }); } catch (error) { throw new Error(`Failed to create certificate store: ${error instanceof Error ? error.message : String(error)}`); } } /** * Handle HTTP request and check if it's an ACME challenge * @param req HTTP request * @param res HTTP response * @returns true if the request was handled as an ACME challenge */ public handleRequest(req: IncomingMessage, res: ServerResponse): boolean { if (!this.http01Handler) { return false; } const url = req.url || '/'; // Check if this is an ACME challenge request if (url.startsWith('/.well-known/acme-challenge/')) { const token = url.split('/').pop() || ''; if (token && this.http01Handler) { try { // Try to delegate to the handler - casting to any for flexibility const handler = this.http01Handler as any; // Different versions may have different handler methods if (typeof handler.handleChallenge === 'function') { handler.handleChallenge(req, res); return true; } else if (typeof handler.handleRequest === 'function') { // Some versions use handleRequest instead handler.handleRequest(req, res); return true; } else { // Fall back to manual response const resp = this.getTokenResponse(token); if (resp) { res.setHeader('Content-Type', 'text/plain'); res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0'); res.writeHead(200); res.end(resp); return true; } } } catch (err) { // Challenge not found } } // Invalid ACME challenge res.writeHead(404); res.end('Not found'); return true; } return false; } /** * Get the response for a specific token if available * This is a fallback method in case direct handler access isn't available */ private getTokenResponse(token: string): string | null { if (!this.http01Handler) return null; try { // Cast to any to handle different versions of the API const handler = this.http01Handler as any; // Try different methods that might be available in different versions if (typeof handler.getResponse === 'function') { return handler.getResponse(token); } if (typeof handler.getChallengeVerification === 'function') { return handler.getChallengeVerification(token); } // Try to access the challenges directly from the handler's internal state if (handler.challenges && typeof handler.challenges === 'object' && handler.challenges[token]) { return handler.challenges[token]; } // Try the token map if it exists (another common pattern) if (handler.tokenMap && typeof handler.tokenMap === 'object' && handler.tokenMap[token]) { return handler.tokenMap[token]; } } catch (err) { console.error('Error getting token response:', err); } return null; } /** * Request a certificate for a domain * @param domain Domain name * @param isRenewal Whether this is a renewal */ public async requestCertificate(domain: string, isRenewal: boolean = false): Promise { if (!this.smartAcme) { throw new Error('ACME client not initialized'); } try { // Request certificate via SmartAcme const certObj = await this.smartAcme.getCertificateForDomain(domain); const certData: CertificateData = { domain, certificate: certObj.publicKey, privateKey: certObj.privateKey, expiryDate: new Date(certObj.validUntil), source: 'http01', isRenewal }; // SmartACME will emit its own events, but we'll emit our own too // for consistency with the rest of the system if (isRenewal) { this.emit(CertificateEvents.CERTIFICATE_RENEWED, certData); } else { this.emit(CertificateEvents.CERTIFICATE_ISSUED, certData); } return certData; } catch (error) { // Construct failure object const failure: CertificateFailure = { domain, error: error instanceof Error ? error.message : String(error), isRenewal, }; // Emit failure event this.emit(CertificateEvents.CERTIFICATE_FAILED, failure); 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 * @param domain Domain name * @param certificate Certificate data * @param thresholdDays Days before expiry to trigger a 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); }); } } } }