import * as plugins from './smartacme.plugins.js'; import { SmartacmeCert } from './smartacme.classes.cert.js'; import { SmartacmeCertManager } from './smartacme.classes.certmanager.js'; import { SmartacmeCertMatcher } from './smartacme.classes.certmatcher.js'; import { commitinfo } from './00_commitinfo_data.js'; /** * the options for the class @see SmartAcme */ export interface ISmartAcmeOptions { accountPrivateKey?: string; accountEmail: string; mongoDescriptor: plugins.smartdata.IMongoDescriptor; setChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise; removeChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise; 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; }; } /** * 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; // challenge fullfillment private setChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise; private removeChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise; // certmanager private certmanager: SmartacmeCertManager; 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[] = []; 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 ?? 3, factor: optionsArg.retryOptions?.factor ?? 2, minTimeoutMs: optionsArg.retryOptions?.minTimeoutMs ?? 1000, maxTimeoutMs: optionsArg.retryOptions?.maxTimeoutMs ?? 30000, }; } /** * 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(); this.setChallenge = this.options.setChallenge; this.removeChallenge = this.options.removeChallenge; // CertMangaer this.certmanager = new SmartacmeCertManager(this, { mongoDescriptor: this.options.mongoDescriptor, }); await this.certmanager.init(); // 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')); } public async stop() { await this.certmanager.smartdataDb.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 challenge of [...this.pendingChallenges]) { try { await this.removeChallenge(challenge); await this.logger.log('info', 'Removed pending challenge during shutdown', challenge); } catch (err) { await this.logger.log('error', 'Failed to remove pending challenge during shutdown', err); } } 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()) { await retrievedCertificate.delete(); } // 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); const fullHostName: string = `_acme-challenge.${authz.identifier.value}`; const dnsChallenge = authz.challenges.find((challengeArg) => { return challengeArg.type === 'dns-01'; }); // process.exit(1); const keyAuthorization: string = await this.client.getChallengeKeyAuthorization(dnsChallenge); // prepare DNS challenge record and track for cleanup const challengeRecord: plugins.tsclass.network.IDnsChallenge = { hostName: fullHostName, challenge: keyAuthorization }; this.pendingChallenges.push(challengeRecord); try { /* Satisfy challenge */ await this.retry(() => this.setChallenge(challengeRecord), 'setChallenge'); await plugins.smartdelay.delayFor(30000); await this.retry(() => this.smartdns.checkUntilAvailable(fullHostName, 'TXT', keyAuthorization, 100, 5000), 'dnsCheckUntilAvailable'); await this.logger.log('info', 'Cooling down extra 60 seconds for DNS regional propagation'); await plugins.smartdelay.delayFor(60000); /* Verify that challenge is satisfied */ await this.retry(() => this.client.verifyChallenge(authz, dnsChallenge), 'verifyChallenge'); /* Notify ACME provider that challenge is satisfied */ await this.retry(() => this.client.completeChallenge(dnsChallenge), 'completeChallenge'); /* Wait for ACME provider to respond with valid status */ await this.retry(() => this.client.waitForValidStatus(dnsChallenge), 'waitForValidStatus'); } finally { /* Clean up challenge response */ try { await this.retry(() => this.removeChallenge(challengeRecord), 'removeChallenge'); } catch (err) { await this.logger.log('error', 'Error removing DNS challenge', err); } finally { // remove from pending list this.pendingChallenges = this.pendingChallenges.filter(c => c !== challengeRecord); } } } /* 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 */ await this.certmanager.storeCertificate({ 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, }), }); const newCertificate = await this.certmanager.retrieveCertificate(certDomainName); currentDomainInterst.fullfillInterest(newCertificate); currentDomainInterst.destroy(); return newCertificate; } public async getAllCertificates(): Promise { return SmartacmeCert.getInstances({}); } }