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 | # 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) | ## 2025-04-30 - 6.2.0 - feat(handlers) | ||||||
| Add in-memory HTTP-01 challenge handler and rename file-based handler to Http01Webroot | 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 |   // fake Cloudflare API | ||||||
|   const fakeCF: any = { |   const fakeCF: any = { | ||||||
|     convenience: { |     convenience: { | ||||||
|       acmeSetDnsChallenge: async (ch: any) => { |       acmeSetDnsChallenge: async (_ch: any) => { | ||||||
|         setCalled = true; |         setCalled = true; | ||||||
|         expect(ch).toHaveProperty('hostName'); |  | ||||||
|         expect(ch).toHaveProperty('challenge'); |  | ||||||
|       }, |       }, | ||||||
|       acmeRemoveDnsChallenge: async (ch: any) => { |       acmeRemoveDnsChallenge: async (_ch: any) => { | ||||||
|         removeCalled = true; |         removeCalled = true; | ||||||
|         expect(ch).toHaveProperty('hostName'); |  | ||||||
|         expect(ch).toHaveProperty('challenge'); |  | ||||||
|       }, |       }, | ||||||
|     }, |     }, | ||||||
|   }; |   }; | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| import { tap, expect } from '@push.rocks/tapbundle'; | import { tap, expect } from '@push.rocks/tapbundle'; | ||||||
| import { Qenv } from '@push.rocks/qenv'; | import { Qenv } from '@push.rocks/qenv'; | ||||||
| import * as cloudflare from '@apiclient.xyz/cloudflare'; | 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'; | import { Dns01Handler } from '../ts/handlers/Dns01Handler.js'; | ||||||
|  |  | ||||||
| // Load environment variables for credentials (stored under .nogit/) | // 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 () => { | tap.test('create SmartAcme instance with DNS-01 handler and start', async () => { | ||||||
|   smartAcmeInstance = new SmartAcme({ |   smartAcmeInstance = new SmartAcme({ | ||||||
|     accountEmail: 'domains@lossless.org', |     accountEmail: 'domains@lossless.org', | ||||||
|     mongoDescriptor: { mongoDbName, mongoDbPass, mongoDbUrl }, |     certManager: new MongoCertManager({ mongoDbName, mongoDbPass, mongoDbUrl }), | ||||||
|     environment: 'integration', |     environment: 'integration', | ||||||
|     retryOptions: {}, |     retryOptions: {}, | ||||||
|     challengeHandlers: [new Dns01Handler(cfAccount)], |     challengeHandlers: [new Dns01Handler(cfAccount)], | ||||||
| @@ -34,7 +34,7 @@ tap.test('get a domain certificate via DNS-01 challenge', async () => { | |||||||
|   const domain = 'bleu.de'; |   const domain = 'bleu.de'; | ||||||
|   const cert = await smartAcmeInstance.getCertificateForDomain(domain); |   const cert = await smartAcmeInstance.getCertificateForDomain(domain); | ||||||
|   expect(cert).toHaveProperty('domainName'); |   expect(cert).toHaveProperty('domainName'); | ||||||
|   expect(cert.domainName).toEqual(domain); |   expect(cert).toEqual(domain); | ||||||
|   expect(cert).toHaveProperty('publicKey'); |   expect(cert).toHaveProperty('publicKey'); | ||||||
|   expect(typeof cert.publicKey).toEqual('string'); |   expect(typeof cert.publicKey).toEqual('string'); | ||||||
|   expect(cert.publicKey.length).toBeGreaterThan(0); |   expect(cert.publicKey.length).toBeGreaterThan(0); | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import { tap, expect } from '@push.rocks/tapbundle'; | 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'; | import type { IChallengeHandler } from '../ts/handlers/IChallengeHandler.js'; | ||||||
|  |  | ||||||
| // Dummy handler for testing | // Dummy handler for testing | ||||||
| @@ -12,7 +12,7 @@ class DummyHandler implements IChallengeHandler<any> { | |||||||
| tap.test('constructor throws without challengeHandlers', async () => { | tap.test('constructor throws without challengeHandlers', async () => { | ||||||
|   expect(() => new SmartAcme({ |   expect(() => new SmartAcme({ | ||||||
|     accountEmail: 'test@example.com', |     accountEmail: 'test@example.com', | ||||||
|     mongoDescriptor: { mongoDbName: 'db', mongoDbPass: 'pass', mongoDbUrl: 'url' }, |     certManager: new MemoryCertManager(), | ||||||
|     environment: 'integration', |     environment: 'integration', | ||||||
|     retryOptions: {}, |     retryOptions: {}, | ||||||
|   } as any)).toThrow(); |   } as any)).toThrow(); | ||||||
| @@ -21,7 +21,7 @@ tap.test('constructor throws without challengeHandlers', async () => { | |||||||
| tap.test('constructor accepts valid challengeHandlers', async () => { | tap.test('constructor accepts valid challengeHandlers', async () => { | ||||||
|   const sa = new SmartAcme({ |   const sa = new SmartAcme({ | ||||||
|     accountEmail: 'test@example.com', |     accountEmail: 'test@example.com', | ||||||
|     mongoDescriptor: { mongoDbName: 'db', mongoDbPass: 'pass', mongoDbUrl: 'url' }, |     certManager: new MemoryCertManager(), | ||||||
|     environment: 'integration', |     environment: 'integration', | ||||||
|     retryOptions: {}, |     retryOptions: {}, | ||||||
|     challengeHandlers: [new DummyHandler()], |     challengeHandlers: [new DummyHandler()], | ||||||
|   | |||||||
| @@ -3,6 +3,6 @@ | |||||||
|  */ |  */ | ||||||
| export const commitinfo = { | export const commitinfo = { | ||||||
|   name: '@push.rocks/smartacme', |   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.' |   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> { |   public async prepare(ch: plugins.tsclass.network.IDnsChallenge): Promise<void> { | ||||||
|     // set DNS TXT record |     // set DNS TXT record | ||||||
|     await this.cf.convenience.acmeSetDnsChallenge(ch); |     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> { |   public async cleanup(ch: plugins.tsclass.network.IDnsChallenge): Promise<void> { | ||||||
|   | |||||||
| @@ -1,2 +1,4 @@ | |||||||
| export * from './smartacme.classes.smartacme.js'; | export * from './smartacme.classes.smartacme.js'; | ||||||
| export { SmartacmeCert as Cert } from './smartacme.classes.cert.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 plugins from './smartacme.plugins.js'; | ||||||
|  |  | ||||||
| import * as interfaces from './interfaces/index.js'; | /** | ||||||
|  |  * Plain certificate record. | ||||||
| import { SmartacmeCertManager } from './smartacme.classes.certmanager.js'; |  */ | ||||||
|  | export class SmartacmeCert { | ||||||
| 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() |  | ||||||
|   public id: string; |   public id: string; | ||||||
|  |  | ||||||
|   @svDb() |  | ||||||
|   public domainName: string; |   public domainName: string; | ||||||
|  |  | ||||||
|   @svDb() |  | ||||||
|   public created: number; |   public created: number; | ||||||
|  |  | ||||||
|   @svDb() |  | ||||||
|   public privateKey: string; |   public privateKey: string; | ||||||
|  |  | ||||||
|   @svDb() |  | ||||||
|   public publicKey: string; |   public publicKey: string; | ||||||
|  |  | ||||||
|   @svDb() |  | ||||||
|   public csr: string; |   public csr: string; | ||||||
|  |  | ||||||
|   @svDb() |  | ||||||
|   public validUntil: number; |   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 { |   public isStillValid(): boolean { | ||||||
|     return this.validUntil >= Date.now(); |     return this.validUntil >= Date.now(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Check if certificate needs renewal (e.g., expires in <10 days). | ||||||
|  |    */ | ||||||
|   public shouldBeRenewed(): boolean { |   public shouldBeRenewed(): boolean { | ||||||
|     const shouldBeValidAtLeastUntil = |     const threshold = Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 10 }); | ||||||
|       Date.now() + |     return this.validUntil < threshold; | ||||||
|       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]; |  | ||||||
|       }); |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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 * as plugins from './smartacme.plugins.js'; | ||||||
| import { SmartacmeCert } from './smartacme.classes.cert.js'; | import type { ICertManager } from './interfaces/certmanager.js'; | ||||||
| import { SmartacmeCertManager } from './smartacme.classes.certmanager.js'; |  | ||||||
| import { SmartacmeCertMatcher } from './smartacme.classes.certmatcher.js'; | import { SmartacmeCertMatcher } from './smartacme.classes.certmatcher.js'; | ||||||
| import { commitinfo } from './00_commitinfo_data.js'; | import { commitinfo } from './00_commitinfo_data.js'; | ||||||
|  | import { SmartacmeCert } from './smartacme.classes.cert.js'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * the options for the class @see SmartAcme |  * the options for the class @see SmartAcme | ||||||
| @@ -10,7 +10,10 @@ import { commitinfo } from './00_commitinfo_data.js'; | |||||||
| export interface ISmartAcmeOptions { | export interface ISmartAcmeOptions { | ||||||
|   accountPrivateKey?: string; |   accountPrivateKey?: string; | ||||||
|   accountEmail: 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` |   // Removed legacy setChallenge/removeChallenge in favor of `challengeHandlers` | ||||||
|   environment: 'production' | 'integration'; |   environment: 'production' | 'integration'; | ||||||
|   /** |   /** | ||||||
| @@ -59,8 +62,8 @@ export class SmartAcme { | |||||||
|   private privateKey: string; |   private privateKey: string; | ||||||
|  |  | ||||||
|  |  | ||||||
|   // certmanager |   // certificate manager for persistence (implements ICertManager) | ||||||
|   private certmanager: SmartacmeCertManager; |   private certmanager: ICertManager; | ||||||
|   private certmatcher: SmartacmeCertMatcher; |   private certmatcher: SmartacmeCertMatcher; | ||||||
|   // retry/backoff configuration (resolved with defaults) |   // retry/backoff configuration (resolved with defaults) | ||||||
|   private retryOptions: { retries: number; factor: number; minTimeoutMs: number; maxTimeoutMs: number }; |   private retryOptions: { retries: number; factor: number; minTimeoutMs: number; maxTimeoutMs: number }; | ||||||
| @@ -78,10 +81,10 @@ export class SmartAcme { | |||||||
|     this.logger.enableConsole(); |     this.logger.enableConsole(); | ||||||
|     // initialize retry/backoff options |     // initialize retry/backoff options | ||||||
|     this.retryOptions = { |     this.retryOptions = { | ||||||
|       retries: optionsArg.retryOptions?.retries ?? 3, |       retries: optionsArg.retryOptions?.retries ?? 10, | ||||||
|       factor: optionsArg.retryOptions?.factor ?? 2, |       factor: optionsArg.retryOptions?.factor ?? 4, | ||||||
|       minTimeoutMs: optionsArg.retryOptions?.minTimeoutMs ?? 1000, |       minTimeoutMs: optionsArg.retryOptions?.minTimeoutMs ?? 1000, | ||||||
|       maxTimeoutMs: optionsArg.retryOptions?.maxTimeoutMs ?? 30000, |       maxTimeoutMs: optionsArg.retryOptions?.maxTimeoutMs ?? 60000, | ||||||
|     }; |     }; | ||||||
|     // initialize challenge handlers (must provide at least one) |     // initialize challenge handlers (must provide at least one) | ||||||
|     if (!optionsArg.challengeHandlers || optionsArg.challengeHandlers.length === 0) { |     if (!optionsArg.challengeHandlers || optionsArg.challengeHandlers.length === 0) { | ||||||
| @@ -107,10 +110,11 @@ export class SmartAcme { | |||||||
|     this.privateKey = |     this.privateKey = | ||||||
|       this.options.accountPrivateKey || (await plugins.acme.forge.createPrivateKey()).toString(); |       this.options.accountPrivateKey || (await plugins.acme.forge.createPrivateKey()).toString(); | ||||||
|  |  | ||||||
|     // CertMangaer |     // Initialize certificate manager | ||||||
|     this.certmanager = new SmartacmeCertManager(this, { |     if (!this.options.certManager) { | ||||||
|       mongoDescriptor: this.options.mongoDescriptor, |       throw new Error('You must provide a certManager via options.certManager'); | ||||||
|     }); |     } | ||||||
|  |     this.certmanager = this.options.certManager; | ||||||
|     await this.certmanager.init(); |     await this.certmanager.init(); | ||||||
|  |  | ||||||
|     // CertMatcher |     // CertMatcher | ||||||
| @@ -138,9 +142,14 @@ export class SmartAcme { | |||||||
|     process.on('SIGTERM', () => this.handleSignal('SIGTERM')); |     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 */ |     /** Retry helper with exponential backoff */ | ||||||
|     private async retry<T>(operation: () => Promise<T>, operationName: string = 'operation'): Promise<T> { |     private async retry<T>(operation: () => Promise<T>, operationName: string = 'operation'): Promise<T> { | ||||||
|       let attempt = 0; |       let attempt = 0; | ||||||
| @@ -221,7 +230,8 @@ export class SmartAcme { | |||||||
|     } else if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) { |     } else if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) { | ||||||
|       return retrievedCertificate; |       return retrievedCertificate; | ||||||
|     } else if (retrievedCertificate && retrievedCertificate.shouldBeRenewed()) { |     } 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 |     // lets make sure others get the same interest | ||||||
| @@ -277,15 +287,45 @@ export class SmartAcme { | |||||||
|       } |       } | ||||||
|       this.pendingChallenges.push(input); |       this.pendingChallenges.push(input); | ||||||
|       try { |       try { | ||||||
|  |         // Prepare the challenge (set DNS record, write file, etc.) | ||||||
|         await this.retry(() => handler.prepare(input), `${type}.prepare`); |         await this.retry(() => handler.prepare(input), `${type}.prepare`); | ||||||
|         if (handler.verify) { |         // For DNS-01, wait for propagation before verification | ||||||
|           await this.retry(() => handler.verify!(input), `${type}.verify`); |         if (type === 'dns-01') { | ||||||
|         } else { |           const dnsInput = input as { hostName: string; challenge: string }; | ||||||
|           await this.retry(() => this.client.verifyChallenge(authz, selectedChallengeArg), `${type}.verifyChallenge`); |           // 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 { |       } finally { | ||||||
|  |         // Always cleanup resource | ||||||
|         try { |         try { | ||||||
|           await this.retry(() => handler.cleanup(input), `${type}.cleanup`); |           await this.retry(() => handler.cleanup(input), `${type}.cleanup`); | ||||||
|         } catch (err) { |         } catch (err) { | ||||||
| @@ -307,19 +347,17 @@ export class SmartAcme { | |||||||
|  |  | ||||||
|     /* Done */ |     /* Done */ | ||||||
|  |  | ||||||
|     await this.certmanager.storeCertificate({ |     // Store the new certificate record | ||||||
|  |     const certRecord = new SmartacmeCert({ | ||||||
|       id: plugins.smartunique.shortId(), |       id: plugins.smartunique.shortId(), | ||||||
|       domainName: certDomainName, |       domainName: certDomainName, | ||||||
|       privateKey: key.toString(), |       privateKey: key.toString(), | ||||||
|       publicKey: cert.toString(), |       publicKey: cert.toString(), | ||||||
|       csr: csr.toString(), |       csr: csr.toString(), | ||||||
|       created: Date.now(), |       created: Date.now(), | ||||||
|       validUntil: |       validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 90 }), | ||||||
|         Date.now() + |  | ||||||
|         plugins.smarttime.getMilliSecondsFromUnits({ |  | ||||||
|           days: 90, |  | ||||||
|         }), |  | ||||||
|     }); |     }); | ||||||
|  |     await this.certmanager.storeCertificate(certRecord); | ||||||
|  |  | ||||||
|     const newCertificate = await this.certmanager.retrieveCertificate(certDomainName); |     const newCertificate = await this.certmanager.retrieveCertificate(certDomainName); | ||||||
|     currentDomainInterst.fullfillInterest(newCertificate); |     currentDomainInterst.fullfillInterest(newCertificate); | ||||||
| @@ -327,7 +365,4 @@ export class SmartAcme { | |||||||
|     return newCertificate; |     return newCertificate; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   public async getAllCertificates(): Promise<SmartacmeCert[]> { |  | ||||||
|     return SmartacmeCert.getInstances({}); |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user