import * as plugins from './plugins.js'; /** * Represents a domain certificate with various status information */ interface IDomainCertificate { certObtained: boolean; obtainingInProgress: boolean; certificate?: string; privateKey?: string; challengeToken?: string; challengeKeyAuthorization?: string; expiryDate?: Date; lastRenewalAttempt?: Date; } /** * Configuration options for the ACME Certificate Manager */ interface IAcmeCertManagerOptions { port?: number; contactEmail?: string; useProduction?: boolean; renewThresholdDays?: number; httpsRedirectPort?: number; renewCheckIntervalHours?: number; } /** * Certificate data that can be emitted via events or set from outside */ interface ICertificateData { domain: string; certificate: string; privateKey: string; expiryDate: Date; } /** * Events emitted by the ACME Certificate Manager */ export enum CertManagerEvents { CERTIFICATE_ISSUED = 'certificate-issued', CERTIFICATE_RENEWED = 'certificate-renewed', CERTIFICATE_FAILED = 'certificate-failed', CERTIFICATE_EXPIRING = 'certificate-expiring', MANAGER_STARTED = 'manager-started', MANAGER_STOPPED = 'manager-stopped', } /** * Improved ACME Certificate Manager with event emission and external certificate management */ export class AcmeCertManager extends plugins.EventEmitter { private domainCertificates: Map; 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; /** * Creates a new ACME Certificate Manager * @param options Configuration options */ constructor(options: IAcmeCertManagerOptions = {}) { super(); this.domainCertificates = new Map(); // Default options this.options = { port: options.port ?? 80, contactEmail: options.contactEmail ?? 'admin@example.com', useProduction: options.useProduction ?? false, // Safer default: staging renewThresholdDays: options.renewThresholdDays ?? 30, httpsRedirectPort: options.httpsRedirectPort ?? 443, renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24, }; } /** * Starts the HTTP server for ACME challenges */ public async start(): Promise { if (this.server) { throw new Error('Server is already running'); } if (this.isShuttingDown) { throw new Error('Server is shutting down'); } return new Promise((resolve, reject) => { try { this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res)); 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.`)); } else if (error.code === 'EADDRINUSE') { reject(new Error(`Port ${this.options.port} is already in use.`)); } else { reject(error); } }); this.server.listen(this.options.port, () => { console.log(`AcmeCertManager is listening on port ${this.options.port}`); this.startRenewalTimer(); this.emit(CertManagerEvents.MANAGER_STARTED, this.options.port); resolve(); }); } catch (error) { reject(error); } }); } /** * Stops the HTTP server and renewal timer */ public async stop(): Promise { if (!this.server) { return; } this.isShuttingDown = true; // Stop the renewal timer if (this.renewalTimer) { clearInterval(this.renewalTimer); this.renewalTimer = null; } return new Promise((resolve) => { if (this.server) { this.server.close(() => { this.server = null; this.isShuttingDown = false; this.emit(CertManagerEvents.MANAGER_STOPPED); resolve(); }); } else { this.isShuttingDown = false; resolve(); } }); } /** * Adds a domain to be managed for certificates * @param domain The domain to add */ public addDomain(domain: string): void { if (!this.domainCertificates.has(domain)) { this.domainCertificates.set(domain, { certObtained: false, obtainingInProgress: false }); console.log(`Domain added: ${domain}`); } } /** * Removes a domain from management * @param domain The domain to remove */ public removeDomain(domain: string): void { if (this.domainCertificates.delete(domain)) { console.log(`Domain removed: ${domain}`); } } /** * Sets a certificate for a domain directly (for externally obtained certificates) * @param domain The domain for the certificate * @param certificate The certificate (PEM format) * @param privateKey The private key (PEM format) * @param expiryDate Optional expiry date */ public setCertificate(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void { let domainInfo = this.domainCertificates.get(domain); if (!domainInfo) { domainInfo = { certObtained: false, obtainingInProgress: false }; this.domainCertificates.set(domain, domainInfo); } domainInfo.certificate = certificate; domainInfo.privateKey = privateKey; domainInfo.certObtained = true; domainInfo.obtainingInProgress = false; 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}`); } } console.log(`Certificate set for ${domain}`); // Emit certificate event this.emitCertificateEvent(CertManagerEvents.CERTIFICATE_ISSUED, { domain, certificate, privateKey, expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default }); } /** * Gets the certificate for a domain if it exists * @param domain The domain to get the certificate for */ public getCertificate(domain: string): ICertificateData | null { const domainInfo = this.domainCertificates.get(domain); if (!domainInfo || !domainInfo.certObtained || !domainInfo.certificate || !domainInfo.privateKey) { return null; } return { domain, certificate: domainInfo.certificate, privateKey: domainInfo.privateKey, expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default }; } /** * Lazy initialization of the ACME client * @returns An ACME client instance */ private async getAcmeClient(): Promise { if (this.acmeClient) { 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; } /** * Handles incoming HTTP requests * @param req The HTTP request * @param res The HTTP response */ private handleRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void { const hostHeader = req.headers.host; if (!hostHeader) { res.statusCode = 400; res.end('Bad Request: Host header is missing'); return; } // 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; } if (!this.domainCertificates.has(domain)) { res.statusCode = 404; res.end('Domain not configured'); return; } const domainInfo = this.domainCertificates.get(domain)!; // If certificate exists, redirect to HTTPS if (domainInfo.certObtained) { const httpsPort = this.options.httpsRedirectPort; const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`; const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`; res.statusCode = 301; res.setHeader('Location', redirectUrl); res.end(`Redirecting to ${redirectUrl}`); } else { // Trigger certificate issuance if not already running if (!domainInfo.obtainingInProgress) { this.obtainCertificate(domain).catch(err => { this.emit(CertManagerEvents.CERTIFICATE_FAILED, { domain, error: err.message }); console.error(`Error obtaining certificate for ${domain}:`, err); }); } res.statusCode = 503; res.end('Certificate issuance in progress, please try again later.'); } } /** * 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 */ private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise { // Get the domain info const domainInfo = this.domainCertificates.get(domain); if (!domainInfo) { throw new Error(`Domain not found: ${domain}`); } // Prevent concurrent certificate issuance if (domainInfo.obtainingInProgress) { console.log(`Certificate issuance already in progress for ${domain}`); return; } 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); 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; } } // 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 domainInfo.certificate = certificate; domainInfo.privateKey = privateKey; domainInfo.certObtained = true; // Clear challenge data delete domainInfo.challengeToken; 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}`); } console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`); // Emit the appropriate event const eventType = isRenewal ? CertManagerEvents.CERTIFICATE_RENEWED : CertManagerEvents.CERTIFICATE_ISSUED; this.emitCertificateEvent(eventType, { domain, certificate, privateKey, expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default }); } 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 this.emit(CertManagerEvents.CERTIFICATE_FAILED, { domain, error: error.message || 'Unknown error', isRenewal }); } finally { // Reset flag whether successful or not domainInfo.obtainingInProgress = false; } } /** * Starts the certificate renewal timer */ private startRenewalTimer(): void { if (this.renewalTimer) { clearInterval(this.renewalTimer); } // Convert hours to milliseconds const checkInterval = this.options.renewCheckIntervalHours * 60 * 60 * 1000; this.renewalTimer = setInterval(() => this.checkForRenewals(), checkInterval); // Prevent the timer from keeping the process alive if (this.renewalTimer.unref) { this.renewalTimer.unref(); } console.log(`Certificate renewal check scheduled every ${this.options.renewCheckIntervalHours} hours`); } /** * Checks for certificates that need renewal */ private checkForRenewals(): void { if (this.isShuttingDown) { return; } console.log('Checking for certificates that need renewal...'); const now = new Date(); const renewThresholdMs = this.options.renewThresholdDays * 24 * 60 * 60 * 1000; for (const [domain, domainInfo] of this.domainCertificates.entries()) { // Skip domains without certificates or already in renewal if (!domainInfo.certObtained || domainInfo.obtainingInProgress) { continue; } // Skip domains without expiry dates if (!domainInfo.expiryDate) { continue; } const timeUntilExpiry = domainInfo.expiryDate.getTime() - now.getTime(); // Check if certificate is near expiry if (timeUntilExpiry <= renewThresholdMs) { console.log(`Certificate for ${domain} expires soon, renewing...`); this.emit(CertManagerEvents.CERTIFICATE_EXPIRING, { domain, expiryDate: domainInfo.expiryDate, daysRemaining: Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000)) }); // Start renewal process this.obtainCertificate(domain, true).catch(err => { console.error(`Error renewing certificate for ${domain}:`, err); }); } } } /** * 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 { this.emit(eventType, data); } }