BREAKING CHANGE(SmartAcme (Cert Management)): Refactor certificate management and challenge handling API to use a unified certManager interface, remove legacy storage, and update challenge workflows.
This commit is contained in:
		
							
								
								
									
										10
									
								
								changelog.md
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								changelog.md
									
									
									
									
									
								
							| @@ -1,5 +1,15 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## 2025-04-30 - 7.0.0 - BREAKING CHANGE(SmartAcme (Cert Management)) | ||||
| Refactor certificate management and challenge handling API to use a unified certManager interface, remove legacy storage, and update challenge workflows. | ||||
|  | ||||
| - Introduce ICertManager interface with MemoryCertManager and MongoCertManager implementations. | ||||
| - Remove the legacy SmartacmeCertManager and update SmartAcme to require a certManager option instead of mongoDescriptor. | ||||
| - Adjust certificate renewal logic to delete and store certificates through the new certManager API. | ||||
| - Refine DNS-01 challenge handling by removing in-handler DNS propagation waiting and relying on external checks. | ||||
| - Increase retry settings for robustness during challenge verification and certificate issuance. | ||||
| - Update integration and unit tests to use the new certManager configuration. | ||||
|  | ||||
| ## 2025-04-30 - 6.2.0 - feat(handlers) | ||||
| Add in-memory HTTP-01 challenge handler and rename file-based handler to Http01Webroot | ||||
|  | ||||
|   | ||||
| @@ -7,15 +7,11 @@ tap.test('Dns01Handler prepare and cleanup calls Cloudflare and DNS functions', | ||||
|   // fake Cloudflare API | ||||
|   const fakeCF: any = { | ||||
|     convenience: { | ||||
|       acmeSetDnsChallenge: async (ch: any) => { | ||||
|       acmeSetDnsChallenge: async (_ch: any) => { | ||||
|         setCalled = true; | ||||
|         expect(ch).toHaveProperty('hostName'); | ||||
|         expect(ch).toHaveProperty('challenge'); | ||||
|       }, | ||||
|       acmeRemoveDnsChallenge: async (ch: any) => { | ||||
|       acmeRemoveDnsChallenge: async (_ch: any) => { | ||||
|         removeCalled = true; | ||||
|         expect(ch).toHaveProperty('hostName'); | ||||
|         expect(ch).toHaveProperty('challenge'); | ||||
|       }, | ||||
|     }, | ||||
|   }; | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { tap, expect } from '@push.rocks/tapbundle'; | ||||
| import { Qenv } from '@push.rocks/qenv'; | ||||
| import * as cloudflare from '@apiclient.xyz/cloudflare'; | ||||
| import { SmartAcme } from '../ts/index.js'; | ||||
| import { SmartAcme, MongoCertManager } from '../ts/index.js'; | ||||
| import { Dns01Handler } from '../ts/handlers/Dns01Handler.js'; | ||||
|  | ||||
| // Load environment variables for credentials (stored under .nogit/) | ||||
| @@ -19,7 +19,7 @@ let smartAcmeInstance: SmartAcme; | ||||
| tap.test('create SmartAcme instance with DNS-01 handler and start', async () => { | ||||
|   smartAcmeInstance = new SmartAcme({ | ||||
|     accountEmail: 'domains@lossless.org', | ||||
|     mongoDescriptor: { mongoDbName, mongoDbPass, mongoDbUrl }, | ||||
|     certManager: new MongoCertManager({ mongoDbName, mongoDbPass, mongoDbUrl }), | ||||
|     environment: 'integration', | ||||
|     retryOptions: {}, | ||||
|     challengeHandlers: [new Dns01Handler(cfAccount)], | ||||
| @@ -34,7 +34,7 @@ tap.test('get a domain certificate via DNS-01 challenge', async () => { | ||||
|   const domain = 'bleu.de'; | ||||
|   const cert = await smartAcmeInstance.getCertificateForDomain(domain); | ||||
|   expect(cert).toHaveProperty('domainName'); | ||||
|   expect(cert.domainName).toEqual(domain); | ||||
|   expect(cert).toEqual(domain); | ||||
|   expect(cert).toHaveProperty('publicKey'); | ||||
|   expect(typeof cert.publicKey).toEqual('string'); | ||||
|   expect(cert.publicKey.length).toBeGreaterThan(0); | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { tap, expect } from '@push.rocks/tapbundle'; | ||||
| import { SmartAcme } from '../ts/index.js'; | ||||
| import { SmartAcme, MemoryCertManager } from '../ts/index.js'; | ||||
| import type { IChallengeHandler } from '../ts/handlers/IChallengeHandler.js'; | ||||
|  | ||||
| // Dummy handler for testing | ||||
| @@ -12,7 +12,7 @@ class DummyHandler implements IChallengeHandler<any> { | ||||
| tap.test('constructor throws without challengeHandlers', async () => { | ||||
|   expect(() => new SmartAcme({ | ||||
|     accountEmail: 'test@example.com', | ||||
|     mongoDescriptor: { mongoDbName: 'db', mongoDbPass: 'pass', mongoDbUrl: 'url' }, | ||||
|     certManager: new MemoryCertManager(), | ||||
|     environment: 'integration', | ||||
|     retryOptions: {}, | ||||
|   } as any)).toThrow(); | ||||
| @@ -21,7 +21,7 @@ tap.test('constructor throws without challengeHandlers', async () => { | ||||
| tap.test('constructor accepts valid challengeHandlers', async () => { | ||||
|   const sa = new SmartAcme({ | ||||
|     accountEmail: 'test@example.com', | ||||
|     mongoDescriptor: { mongoDbName: 'db', mongoDbPass: 'pass', mongoDbUrl: 'url' }, | ||||
|     certManager: new MemoryCertManager(), | ||||
|     environment: 'integration', | ||||
|     retryOptions: {}, | ||||
|     challengeHandlers: [new DummyHandler()], | ||||
|   | ||||
| @@ -3,6 +3,6 @@ | ||||
|  */ | ||||
| export const commitinfo = { | ||||
|   name: '@push.rocks/smartacme', | ||||
|   version: '6.2.0', | ||||
|   version: '7.0.0', | ||||
|   description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.' | ||||
| } | ||||
|   | ||||
							
								
								
									
										90
									
								
								ts/certmanagers.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								ts/certmanagers.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,90 @@ | ||||
| import * as plugins from './smartacme.plugins.js'; | ||||
| import type { ICertManager } from './interfaces/certmanager.js'; | ||||
| import { SmartacmeCert } from './smartacme.classes.cert.js'; | ||||
|  | ||||
| /** | ||||
|  * In-memory certificate manager for mongoless mode. | ||||
|  * Stores certificates in memory only and does not connect to MongoDB. | ||||
|  */ | ||||
| export class MemoryCertManager implements ICertManager { | ||||
|   public interestMap: plugins.lik.InterestMap<string, SmartacmeCert>; | ||||
|   private certs: Map<string, SmartacmeCert> = new Map(); | ||||
|  | ||||
|   constructor() { | ||||
|     this.interestMap = new plugins.lik.InterestMap((domain) => domain); | ||||
|   } | ||||
|  | ||||
|   public async init(): Promise<void> { | ||||
|     // no-op for in-memory store | ||||
|   } | ||||
|  | ||||
|   public async retrieveCertificate(domainName: string): Promise<SmartacmeCert | null> { | ||||
|     return this.certs.get(domainName) ?? null; | ||||
|   } | ||||
|  | ||||
|   public async storeCertificate(cert: SmartacmeCert): Promise<void> { | ||||
|     this.certs.set(cert.domainName, cert); | ||||
|     const interest = this.interestMap.findInterest(cert.domainName); | ||||
|     if (interest) { | ||||
|       interest.fullfillInterest(cert); | ||||
|       interest.markLost(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public async deleteCertificate(domainName: string): Promise<void> { | ||||
|     this.certs.delete(domainName); | ||||
|   } | ||||
|  | ||||
|   public async close(): Promise<void> { | ||||
|     // no-op | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * MongoDB-backed certificate manager using EasyStore from smartdata. | ||||
|  */ | ||||
| export class MongoCertManager implements ICertManager { | ||||
|   public interestMap: plugins.lik.InterestMap<string, SmartacmeCert>; | ||||
|   private db: plugins.smartdata.SmartdataDb; | ||||
|   private store: plugins.smartdata.EasyStore<Record<string, any>>; | ||||
|  | ||||
|   /** | ||||
|    * @param mongoDescriptor MongoDB connection settings | ||||
|    */ | ||||
|   constructor(mongoDescriptor: plugins.smartdata.IMongoDescriptor) { | ||||
|     this.db = new plugins.smartdata.SmartdataDb(mongoDescriptor); | ||||
|     // Use a single EasyStore document to hold all certs keyed by domainName | ||||
|     this.store = new plugins.smartdata.EasyStore<Record<string, any>>( | ||||
|       'smartacme-certs', | ||||
|       this.db, | ||||
|     ); | ||||
|     this.interestMap = new plugins.lik.InterestMap((domain) => domain); | ||||
|   } | ||||
|  | ||||
|   public async init(): Promise<void> { | ||||
|     await this.db.init(); | ||||
|   } | ||||
|  | ||||
|   public async retrieveCertificate(domainName: string): Promise<SmartacmeCert | null> { | ||||
|     const data = await this.store.readKey(domainName); | ||||
|     return data ? new SmartacmeCert(data) : null; | ||||
|   } | ||||
|  | ||||
|   public async storeCertificate(cert: SmartacmeCert): Promise<void> { | ||||
|     // write plain object for persistence | ||||
|     await this.store.writeKey(cert.domainName, { ...cert }); | ||||
|     const interest = this.interestMap.findInterest(cert.domainName); | ||||
|     if (interest) { | ||||
|       interest.fullfillInterest(cert); | ||||
|       interest.markLost(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public async deleteCertificate(domainName: string): Promise<void> { | ||||
|     await this.store.deleteKey(domainName); | ||||
|   } | ||||
|  | ||||
|   public async close(): Promise<void> { | ||||
|     await this.db.close(); | ||||
|   } | ||||
| } | ||||
| @@ -23,14 +23,6 @@ export class Dns01Handler implements IChallengeHandler<plugins.tsclass.network.I | ||||
|   public async prepare(ch: plugins.tsclass.network.IDnsChallenge): Promise<void> { | ||||
|     // set DNS TXT record | ||||
|     await this.cf.convenience.acmeSetDnsChallenge(ch); | ||||
|     // wait for DNS propagation | ||||
|     await this.smartdns.checkUntilAvailable( | ||||
|       ch.hostName, | ||||
|       'TXT', | ||||
|       ch.challenge, | ||||
|       100, | ||||
|       5000, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   public async cleanup(ch: plugins.tsclass.network.IDnsChallenge): Promise<void> { | ||||
|   | ||||
| @@ -1,2 +1,4 @@ | ||||
| export * from './smartacme.classes.smartacme.js'; | ||||
| export { SmartacmeCert as Cert } from './smartacme.classes.cert.js'; | ||||
| export type { ICertManager } from './interfaces/certmanager.js'; | ||||
| export { MemoryCertManager, MongoCertManager } from './certmanagers.js'; | ||||
|   | ||||
							
								
								
									
										37
									
								
								ts/interfaces/certmanager.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								ts/interfaces/certmanager.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| import type { InterestMap } from '@push.rocks/lik'; | ||||
| import type { SmartacmeCert } from '../smartacme.classes.cert.js'; | ||||
|  | ||||
| // (ICertRecord removed; use SmartacmeCert directly) | ||||
|  | ||||
| /** | ||||
|  * Interface for certificate storage managers. | ||||
|  * Users can implement this to provide custom persistence (in-memory, | ||||
|  * file-based, Redis, etc.). | ||||
|  */ | ||||
| export interface ICertManager { | ||||
|   /** | ||||
|    * Map for coordinating concurrent certificate requests. | ||||
|    */ | ||||
|   interestMap: InterestMap<string, SmartacmeCert>; | ||||
|   /** | ||||
|    * Initialize the store (e.g., connect to database). | ||||
|    */ | ||||
|   init(): Promise<void>; | ||||
|   /** | ||||
|    * Retrieve a certificate record by domain name. | ||||
|    * Returns null if none found. | ||||
|    */ | ||||
|   retrieveCertificate(domainName: string): Promise<SmartacmeCert | null>; | ||||
|   /** | ||||
|    * Store a certificate record. Fulfills any pending interests. | ||||
|    */ | ||||
|   storeCertificate(cert: SmartacmeCert): Promise<void>; | ||||
|   /** | ||||
|    * Delete a certificate record by domain name. | ||||
|    */ | ||||
|   deleteCertificate(domainName: string): Promise<void>; | ||||
|   /** | ||||
|    * Close the store (e.g., disconnect database). | ||||
|    */ | ||||
|   close(): Promise<void>; | ||||
| } | ||||
| @@ -1,64 +1,39 @@ | ||||
| import * as plugins from './smartacme.plugins.js'; | ||||
|  | ||||
| import * as interfaces from './interfaces/index.js'; | ||||
|  | ||||
| import { SmartacmeCertManager } from './smartacme.classes.certmanager.js'; | ||||
|  | ||||
| import { Collection, svDb, unI } from '@push.rocks/smartdata'; | ||||
|  | ||||
| @plugins.smartdata.Collection(() => { | ||||
|   return SmartacmeCertManager.activeDB; | ||||
| }) | ||||
| export class SmartacmeCert | ||||
|   extends plugins.smartdata.SmartDataDbDoc<SmartacmeCert, plugins.tsclass.network.ICert> | ||||
|   implements plugins.tsclass.network.ICert | ||||
| { | ||||
|   @unI() | ||||
| /** | ||||
|  * Plain certificate record. | ||||
|  */ | ||||
| export class SmartacmeCert { | ||||
|   public id: string; | ||||
|  | ||||
|   @svDb() | ||||
|   public domainName: string; | ||||
|  | ||||
|   @svDb() | ||||
|   public created: number; | ||||
|  | ||||
|   @svDb() | ||||
|   public privateKey: string; | ||||
|  | ||||
|   @svDb() | ||||
|   public publicKey: string; | ||||
|  | ||||
|   @svDb() | ||||
|   public csr: string; | ||||
|  | ||||
|   @svDb() | ||||
|   public validUntil: number; | ||||
|  | ||||
|   constructor(data: Partial<SmartacmeCert> = {}) { | ||||
|     this.id = data.id || ''; | ||||
|     this.domainName = data.domainName || ''; | ||||
|     this.created = data.created || Date.now(); | ||||
|     this.privateKey = data.privateKey || ''; | ||||
|     this.publicKey = data.publicKey || ''; | ||||
|     this.csr = data.csr || ''; | ||||
|     this.validUntil = data.validUntil || 0; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check if certificate is still valid. | ||||
|    */ | ||||
|   public isStillValid(): boolean { | ||||
|     return this.validUntil >= Date.now(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check if certificate needs renewal (e.g., expires in <10 days). | ||||
|    */ | ||||
|   public shouldBeRenewed(): boolean { | ||||
|     const shouldBeValidAtLeastUntil = | ||||
|       Date.now() + | ||||
|       plugins.smarttime.getMilliSecondsFromUnits({ | ||||
|         days: 10, | ||||
|       }); | ||||
|     return !(this.validUntil >= shouldBeValidAtLeastUntil); | ||||
|   } | ||||
|  | ||||
|   public update(certDataArg: plugins.tsclass.network.ICert) { | ||||
|     Object.keys(certDataArg).forEach((key) => { | ||||
|       this[key] = certDataArg[key]; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   constructor(optionsArg: plugins.tsclass.network.ICert) { | ||||
|     super(); | ||||
|     if (optionsArg) { | ||||
|       Object.keys(optionsArg).forEach((key) => { | ||||
|         this[key] = optionsArg[key]; | ||||
|       }); | ||||
|     } | ||||
|     const threshold = Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 10 }); | ||||
|     return this.validUntil < threshold; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,77 +0,0 @@ | ||||
| import * as plugins from './smartacme.plugins.js'; | ||||
| import { SmartacmeCert } from './smartacme.classes.cert.js'; | ||||
| import { SmartAcme } from './smartacme.classes.smartacme.js'; | ||||
|  | ||||
| import * as interfaces from './interfaces/index.js'; | ||||
|  | ||||
| export class SmartacmeCertManager { | ||||
|   // ========= | ||||
|   // STATIC | ||||
|   // ========= | ||||
|   public static activeDB: plugins.smartdata.SmartdataDb; | ||||
|  | ||||
|   // ========= | ||||
|   // INSTANCE | ||||
|   // ========= | ||||
|   private mongoDescriptor: plugins.smartdata.IMongoDescriptor; | ||||
|   public smartdataDb: plugins.smartdata.SmartdataDb; | ||||
|  | ||||
|   public interestMap: plugins.lik.InterestMap<string, SmartacmeCert>; | ||||
|  | ||||
|   constructor( | ||||
|     smartAcmeArg: SmartAcme, | ||||
|     optionsArg: { | ||||
|       mongoDescriptor: plugins.smartdata.IMongoDescriptor; | ||||
|     }, | ||||
|   ) { | ||||
|     this.mongoDescriptor = optionsArg.mongoDescriptor; | ||||
|   } | ||||
|  | ||||
|   public async init() { | ||||
|     // Smartdata DB | ||||
|     this.smartdataDb = new plugins.smartdata.SmartdataDb(this.mongoDescriptor); | ||||
|     await this.smartdataDb.init(); | ||||
|     SmartacmeCertManager.activeDB = this.smartdataDb; | ||||
|  | ||||
|     // Pending Map | ||||
|     this.interestMap = new plugins.lik.InterestMap((certName) => certName); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * retrieves a certificate | ||||
|    * @returns the Cert class or null | ||||
|    * @param certDomainNameArg the domain Name to retrieve the vcertificate for | ||||
|    */ | ||||
|   public async retrieveCertificate(certDomainNameArg: string): Promise<SmartacmeCert> { | ||||
|     const existingCertificate: SmartacmeCert = await SmartacmeCert.getInstance<SmartacmeCert>({ | ||||
|       domainName: certDomainNameArg, | ||||
|     }); | ||||
|  | ||||
|     if (existingCertificate) { | ||||
|       return existingCertificate; | ||||
|     } else { | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * stores the certificate | ||||
|    * @param optionsArg | ||||
|    */ | ||||
|   public async storeCertificate(optionsArg: plugins.tsclass.network.ICert) { | ||||
|     const cert = new SmartacmeCert(optionsArg); | ||||
|     await cert.save(); | ||||
|     const interest = this.interestMap.findInterest(cert.domainName); | ||||
|     if (interest) { | ||||
|       interest.fullfillInterest(cert); | ||||
|       interest.markLost(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public async deleteCertificate(certDomainNameArg: string) { | ||||
|     const cert: SmartacmeCert = await SmartacmeCert.getInstance<SmartacmeCert>({ | ||||
|       domainName: certDomainNameArg, | ||||
|     }); | ||||
|     await cert.delete(); | ||||
|   } | ||||
| } | ||||
| @@ -1,8 +1,8 @@ | ||||
| import * as plugins from './smartacme.plugins.js'; | ||||
| import { SmartacmeCert } from './smartacme.classes.cert.js'; | ||||
| import { SmartacmeCertManager } from './smartacme.classes.certmanager.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 | ||||
| @@ -10,7 +10,10 @@ import { commitinfo } from './00_commitinfo_data.js'; | ||||
| export interface ISmartAcmeOptions { | ||||
|   accountPrivateKey?: string; | ||||
|   accountEmail: string; | ||||
|   mongoDescriptor: plugins.smartdata.IMongoDescriptor; | ||||
|   /** | ||||
|    * Certificate storage manager (e.g., Mongo or in-memory). | ||||
|    */ | ||||
|   certManager: ICertManager; | ||||
|   // Removed legacy setChallenge/removeChallenge in favor of `challengeHandlers` | ||||
|   environment: 'production' | 'integration'; | ||||
|   /** | ||||
| @@ -59,8 +62,8 @@ export class SmartAcme { | ||||
|   private privateKey: string; | ||||
|  | ||||
|  | ||||
|   // certmanager | ||||
|   private certmanager: SmartacmeCertManager; | ||||
|   // 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 }; | ||||
| @@ -78,10 +81,10 @@ export class SmartAcme { | ||||
|     this.logger.enableConsole(); | ||||
|     // initialize retry/backoff options | ||||
|     this.retryOptions = { | ||||
|       retries: optionsArg.retryOptions?.retries ?? 3, | ||||
|       factor: optionsArg.retryOptions?.factor ?? 2, | ||||
|       retries: optionsArg.retryOptions?.retries ?? 10, | ||||
|       factor: optionsArg.retryOptions?.factor ?? 4, | ||||
|       minTimeoutMs: optionsArg.retryOptions?.minTimeoutMs ?? 1000, | ||||
|       maxTimeoutMs: optionsArg.retryOptions?.maxTimeoutMs ?? 30000, | ||||
|       maxTimeoutMs: optionsArg.retryOptions?.maxTimeoutMs ?? 60000, | ||||
|     }; | ||||
|     // initialize challenge handlers (must provide at least one) | ||||
|     if (!optionsArg.challengeHandlers || optionsArg.challengeHandlers.length === 0) { | ||||
| @@ -107,10 +110,11 @@ export class SmartAcme { | ||||
|     this.privateKey = | ||||
|       this.options.accountPrivateKey || (await plugins.acme.forge.createPrivateKey()).toString(); | ||||
|  | ||||
|     // CertMangaer | ||||
|     this.certmanager = new SmartacmeCertManager(this, { | ||||
|       mongoDescriptor: this.options.mongoDescriptor, | ||||
|     }); | ||||
|     // 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(); | ||||
|  | ||||
|     // CertMatcher | ||||
| @@ -138,9 +142,14 @@ export class SmartAcme { | ||||
|     process.on('SIGTERM', () => this.handleSignal('SIGTERM')); | ||||
|   } | ||||
|  | ||||
|     public async stop() { | ||||
|       await this.certmanager.smartdataDb.close(); | ||||
|   /** | ||||
|    * 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<T>(operation: () => Promise<T>, operationName: string = 'operation'): Promise<T> { | ||||
|       let attempt = 0; | ||||
| @@ -221,7 +230,8 @@ export class SmartAcme { | ||||
|     } else if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) { | ||||
|       return retrievedCertificate; | ||||
|     } else if (retrievedCertificate && retrievedCertificate.shouldBeRenewed()) { | ||||
|       await retrievedCertificate.delete(); | ||||
|       // Remove old certificate via certManager | ||||
|       await this.certmanager.deleteCertificate(certDomainName); | ||||
|     } | ||||
|  | ||||
|     // lets make sure others get the same interest | ||||
| @@ -277,15 +287,45 @@ export class SmartAcme { | ||||
|       } | ||||
|       this.pendingChallenges.push(input); | ||||
|       try { | ||||
|         // Prepare the challenge (set DNS record, write file, etc.) | ||||
|         await this.retry(() => handler.prepare(input), `${type}.prepare`); | ||||
|         if (handler.verify) { | ||||
|           await this.retry(() => handler.verify!(input), `${type}.verify`); | ||||
|         } else { | ||||
|           await this.retry(() => this.client.verifyChallenge(authz, selectedChallengeArg), `${type}.verifyChallenge`); | ||||
|         // 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, | ||||
|           ); | ||||
|         } | ||||
|         await this.retry(() => this.client.completeChallenge(selectedChallengeArg), `${type}.completeChallenge`); | ||||
|         await this.retry(() => this.client.waitForValidStatus(selectedChallengeArg), `${type}.waitForValidStatus`); | ||||
|       } finally { | ||||
|         // Always cleanup resource | ||||
|         try { | ||||
|           await this.retry(() => handler.cleanup(input), `${type}.cleanup`); | ||||
|         } catch (err) { | ||||
| @@ -307,19 +347,17 @@ export class SmartAcme { | ||||
|  | ||||
|     /* Done */ | ||||
|  | ||||
|     await this.certmanager.storeCertificate({ | ||||
|     // 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, | ||||
|         }), | ||||
|       validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 90 }), | ||||
|     }); | ||||
|     await this.certmanager.storeCertificate(certRecord); | ||||
|  | ||||
|     const newCertificate = await this.certmanager.retrieveCertificate(certDomainName); | ||||
|     currentDomainInterst.fullfillInterest(newCertificate); | ||||
| @@ -327,7 +365,4 @@ export class SmartAcme { | ||||
|     return newCertificate; | ||||
|   } | ||||
|  | ||||
|   public async getAllCertificates(): Promise<SmartacmeCert[]> { | ||||
|     return SmartacmeCert.getInstances({}); | ||||
|   } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user