import * as plugins from './smartacme.plugins.js'; import type { ICertManager } from './interfaces/certmanager.js'; import { SmartacmeCertMatcher } from './smartacme.classes.certmatcher.js'; import { commitinfo } from './00_commitinfo_data.js'; import { SmartacmeCert } from './smartacme.classes.cert.js'; /** * the options for the class @see SmartAcme */ export interface ISmartAcmeOptions { accountPrivateKey?: string; accountEmail: string; /** * Certificate storage manager (e.g., Mongo or in-memory). */ certManager: ICertManager; // Removed legacy setChallenge/removeChallenge in favor of `challengeHandlers` environment: 'production' | 'integration'; /** * Optional retry/backoff configuration for transient failures */ retryOptions?: { /** number of retry attempts */ retries?: number; /** backoff multiplier */ factor?: number; /** initial delay in milliseconds */ minTimeoutMs?: number; /** maximum delay cap in milliseconds */ maxTimeoutMs?: number; }; /** * Pluggable ACME challenge handlers (DNS-01, HTTP-01, TLS-ALPN-01, etc.) */ challengeHandlers?: plugins.handlers.IChallengeHandler[]; /** * Order of challenge types to try (e.g. ['http-01','dns-01']). * Defaults to ['dns-01'] or first supported type from handlers. */ challengePriority?: string[]; } /** * class SmartAcme * can be used for setting up communication with an ACME authority * * ```ts * const mySmartAcmeInstance = new SmartAcme({ * // see ISmartAcmeOptions for options * }) * ``` */ export class SmartAcme { private options: ISmartAcmeOptions; // the acme client private client: plugins.acme.Client; private smartdns = new plugins.smartdnsClient.Smartdns({}); public logger: plugins.smartlog.Smartlog; // the account private key private privateKey: string; // certificate manager for persistence (implements ICertManager) private certmanager: ICertManager; private certmatcher: SmartacmeCertMatcher; // retry/backoff configuration (resolved with defaults) private retryOptions: { retries: number; factor: number; minTimeoutMs: number; maxTimeoutMs: number }; // track pending DNS challenges for graceful shutdown private pendingChallenges: plugins.tsclass.network.IDnsChallenge[] = []; // configured pluggable ACME challenge handlers private challengeHandlers: plugins.handlers.IChallengeHandler[]; // priority order of challenge types private challengePriority: string[]; constructor(optionsArg: ISmartAcmeOptions) { this.options = optionsArg; this.logger = plugins.smartlog.Smartlog.createForCommitinfo(commitinfo); // enable console output for structured logging this.logger.enableConsole(); // initialize retry/backoff options this.retryOptions = { retries: optionsArg.retryOptions?.retries ?? 10, factor: optionsArg.retryOptions?.factor ?? 4, minTimeoutMs: optionsArg.retryOptions?.minTimeoutMs ?? 1000, maxTimeoutMs: optionsArg.retryOptions?.maxTimeoutMs ?? 60000, }; // initialize challenge handlers (must provide at least one) if (!optionsArg.challengeHandlers || optionsArg.challengeHandlers.length === 0) { throw new Error( 'You must provide at least one ACME challenge handler via options.challengeHandlers', ); } this.challengeHandlers = optionsArg.challengeHandlers; // initialize challenge priority this.challengePriority = optionsArg.challengePriority && optionsArg.challengePriority.length > 0 ? optionsArg.challengePriority : this.challengeHandlers.map((h) => h.getSupportedTypes()[0]); } /** * starts the instance * ```ts * await myCloudlyInstance.start() // does not support options * ``` */ public async start() { this.privateKey = this.options.accountPrivateKey || (await plugins.acme.forge.createPrivateKey()).toString(); // Initialize certificate manager if (!this.options.certManager) { throw new Error('You must provide a certManager via options.certManager'); } this.certmanager = this.options.certManager; await this.certmanager.init(); // For integration environment, clear any existing certificates to avoid stale entries if (this.options.environment === 'integration' && typeof (this.certmanager as any).wipe === 'function') { // this.logger.log('warn', 'Wiping existing certificates for integration environment'); // await (this.certmanager as any).wipe(); } // CertMatcher this.certmatcher = new SmartacmeCertMatcher(); // ACME Client this.client = new plugins.acme.Client({ directoryUrl: (() => { if (this.options.environment === 'production') { return plugins.acme.directory.letsencrypt.production; } else { return plugins.acme.directory.letsencrypt.staging; } })(), accountKey: this.privateKey, }); /* Register account */ await this.client.createAccount({ termsOfServiceAgreed: true, contact: [`mailto:${this.options.accountEmail}`], }); // Setup graceful shutdown handlers process.on('SIGINT', () => this.handleSignal('SIGINT')); process.on('SIGTERM', () => this.handleSignal('SIGTERM')); } /** * Stops the SmartAcme instance and closes certificate store connections. */ public async stop() { if (this.certmanager && typeof (this.certmanager as any).close === 'function') { await (this.certmanager as any).close(); } } /** Retry helper with exponential backoff */ private async retry(operation: () => Promise, operationName: string = 'operation'): Promise { let attempt = 0; let delay = this.retryOptions.minTimeoutMs; while (true) { try { return await operation(); } catch (err) { attempt++; if (attempt > this.retryOptions.retries) { await this.logger.log('error', `Operation ${operationName} failed after ${attempt} attempts`, err); throw err; } await this.logger.log('warn', `Operation ${operationName} failed on attempt ${attempt}, retrying in ${delay}ms`, err); await plugins.smartdelay.delayFor(delay); delay = Math.min(delay * this.retryOptions.factor, this.retryOptions.maxTimeoutMs); } } } /** Clean up pending challenges and shut down */ private async handleShutdown(): Promise { for (const input of [...this.pendingChallenges]) { const type: string = (input as any).type; const handler = this.challengeHandlers.find((h) => h.getSupportedTypes().includes(type)); if (handler) { try { await handler.cleanup(input); await this.logger.log('info', `Removed pending ${type} challenge during shutdown`, input); } catch (err) { await this.logger.log('error', `Failed to remove pending ${type} challenge during shutdown`, err); } } else { await this.logger.log( 'warn', `No handler for pending challenge type '${type}' during shutdown; skipping cleanup`, input, ); } } this.pendingChallenges = []; await this.stop(); } /** Handle process signals for graceful shutdown */ private handleSignal(sig: string): void { this.logger.log('info', `Received signal ${sig}, shutting down gracefully`); this.handleShutdown() .then(() => process.exit(0)) .catch((err) => { this.logger.log('error', 'Error during shutdown', err).then(() => process.exit(1)); }); } /** * gets a certificate * it runs through the following steps * * * look in the database * * if in the database and still valid return it * * if not in the database announce it * * then get it from letsencrypt * * store it * * remove it from the pending map (which it go onto by announcing it) * * retrieve it from the databse and return it * * @param domainArg */ public async getCertificateForDomain(domainArg: string): Promise { const certDomainName = this.certmatcher.getCertificateDomainNameByDomainName(domainArg); const retrievedCertificate = await this.certmanager.retrieveCertificate(certDomainName); if ( !retrievedCertificate && (await this.certmanager.interestMap.checkInterest(certDomainName)) ) { const existingCertificateInterest = this.certmanager.interestMap.findInterest(certDomainName); const certificate = existingCertificateInterest.interestFullfilled; return certificate; } else if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) { return retrievedCertificate; } else if (retrievedCertificate && retrievedCertificate.shouldBeRenewed()) { // Remove old certificate via certManager await this.certmanager.deleteCertificate(certDomainName); } // lets make sure others get the same interest const currentDomainInterst = await this.certmanager.interestMap.addInterest(certDomainName); /* Place new order with retry */ const order = await this.retry(() => this.client.createOrder({ identifiers: [ { type: 'dns', value: certDomainName }, { type: 'dns', value: `*.${certDomainName}` }, ], }), 'createOrder'); /* Get authorizations and select challenges */ const authorizations = await this.retry(() => this.client.getAuthorizations(order), 'getAuthorizations'); for (const authz of authorizations) { await this.logger.log('debug', 'Authorization received', authz); // select a handler based on configured priority let selectedHandler: { type: string; handler: plugins.handlers.IChallengeHandler } | null = null; let selectedChallengeArg: any = null; for (const type of this.challengePriority) { const candidate = authz.challenges.find((c: any) => c.type === type); if (!candidate) continue; const handler = this.challengeHandlers.find((h) => h.getSupportedTypes().includes(type)); if (handler) { selectedHandler = { type, handler }; selectedChallengeArg = candidate; break; } } if (!selectedHandler) { throw new Error(`No challenge handler for domain ${authz.identifier.value}: supported types [${this.challengePriority.join(',')}]`); } const { type, handler } = selectedHandler; // build handler input with keyAuthorization let input: any; // retrieve keyAuthorization for challenge const keyAuth = await this.client.getChallengeKeyAuthorization(selectedChallengeArg); if (type === 'dns-01') { input = { type, hostName: `_acme-challenge.${authz.identifier.value}`, challenge: keyAuth }; } else if (type === 'http-01') { // HTTP-01 requires serving token at webPath input = { type, token: (selectedChallengeArg as any).token, keyAuthorization: keyAuth, webPath: `/.well-known/acme-challenge/${(selectedChallengeArg as any).token}`, }; } else { // generic challenge input: include raw challenge properties input = { type, keyAuthorization: keyAuth, ...selectedChallengeArg }; } this.pendingChallenges.push(input); try { // Prepare the challenge (set DNS record, write file, etc.) await this.retry(() => handler.prepare(input), `${type}.prepare`); // For DNS-01, wait for propagation before verification if (type === 'dns-01') { const dnsInput = input as { hostName: string; challenge: string }; // Wait for authoritative DNS propagation before ACME verify await this.retry( () => this.smartdns.checkUntilAvailable(dnsInput.hostName, 'TXT', dnsInput.challenge, 100, 5000), `${type}.propagation`, ); // Extra cool-down to ensure ACME server sees the new TXT record this.logger.log('info', 'Cooling down for 1 minute before ACME verification'); await plugins.smartdelay.delayFor(60000); } // Official ACME verification (ensures challenge is publicly reachable) await this.retry( () => this.client.verifyChallenge(authz, selectedChallengeArg), `${type}.verifyChallenge`, ); // Notify ACME server to complete the challenge await this.retry( () => this.client.completeChallenge(selectedChallengeArg), `${type}.completeChallenge`, ); // Wait for valid status (warnings on staging timeouts) try { await this.retry( () => this.client.waitForValidStatus(selectedChallengeArg), `${type}.waitForValidStatus`, ); } catch (err) { await this.logger.log( 'warn', `Challenge ${type} did not reach valid status in time, proceeding to finalize`, err, ); } } finally { // Always cleanup resource try { await this.retry(() => handler.cleanup(input), `${type}.cleanup`); } catch (err) { await this.logger.log('error', `Error during ${type}.cleanup`, err); } finally { this.pendingChallenges = this.pendingChallenges.filter((c) => c !== input); } } } /* Finalize order */ const [key, csr] = await plugins.acme.forge.createCsr({ commonName: `*.${certDomainName}`, altNames: [certDomainName], }); await this.retry(() => this.client.finalizeOrder(order, csr), 'finalizeOrder'); const cert = await this.retry(() => this.client.getCertificate(order), 'getCertificate'); /* Done */ // Store the new certificate record const certRecord = new SmartacmeCert({ id: plugins.smartunique.shortId(), domainName: certDomainName, privateKey: key.toString(), publicKey: cert.toString(), csr: csr.toString(), created: Date.now(), validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 90 }), }); await this.certmanager.storeCertificate(certRecord); const newCertificate = await this.certmanager.retrieveCertificate(certDomainName); currentDomainInterst.fullfillInterest(newCertificate); currentDomainInterst.destroy(); return newCertificate; } }