diff --git a/changelog.md b/changelog.md index b4f36d0..0129751 100644 --- a/changelog.md +++ b/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 diff --git a/test/test.handlers-dns01.ts b/test/test.handlers-dns01.ts index 13f6805..e86a702 100644 --- a/test/test.handlers-dns01.ts +++ b/test/test.handlers-dns01.ts @@ -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'); }, }, }; diff --git a/test/test.smartacme.integration.ts b/test/test.smartacme.integration.ts index 426c01d..8b66744 100644 --- a/test/test.smartacme.integration.ts +++ b/test/test.smartacme.integration.ts @@ -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); diff --git a/test/test.smartacme.ts b/test/test.smartacme.ts index 595b6e4..d7630a1 100644 --- a/test/test.smartacme.ts +++ b/test/test.smartacme.ts @@ -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 { 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()], diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index f81a2c1..aad7cec 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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.' } diff --git a/ts/certmanagers.ts b/ts/certmanagers.ts new file mode 100644 index 0000000..f1ce95f --- /dev/null +++ b/ts/certmanagers.ts @@ -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; + private certs: Map = new Map(); + + constructor() { + this.interestMap = new plugins.lik.InterestMap((domain) => domain); + } + + public async init(): Promise { + // no-op for in-memory store + } + + public async retrieveCertificate(domainName: string): Promise { + return this.certs.get(domainName) ?? null; + } + + public async storeCertificate(cert: SmartacmeCert): Promise { + 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 { + this.certs.delete(domainName); + } + + public async close(): Promise { + // no-op + } +} + +/** + * MongoDB-backed certificate manager using EasyStore from smartdata. + */ +export class MongoCertManager implements ICertManager { + public interestMap: plugins.lik.InterestMap; + private db: plugins.smartdata.SmartdataDb; + private store: plugins.smartdata.EasyStore>; + + /** + * @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>( + 'smartacme-certs', + this.db, + ); + this.interestMap = new plugins.lik.InterestMap((domain) => domain); + } + + public async init(): Promise { + await this.db.init(); + } + + public async retrieveCertificate(domainName: string): Promise { + const data = await this.store.readKey(domainName); + return data ? new SmartacmeCert(data) : null; + } + + public async storeCertificate(cert: SmartacmeCert): Promise { + // 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 { + await this.store.deleteKey(domainName); + } + + public async close(): Promise { + await this.db.close(); + } +} \ No newline at end of file diff --git a/ts/handlers/Dns01Handler.ts b/ts/handlers/Dns01Handler.ts index fade514..5535da7 100644 --- a/ts/handlers/Dns01Handler.ts +++ b/ts/handlers/Dns01Handler.ts @@ -23,14 +23,6 @@ export class Dns01Handler implements IChallengeHandler { // 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 { diff --git a/ts/index.ts b/ts/index.ts index e275e0e..c5705d2 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -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'; diff --git a/ts/interfaces/certmanager.ts b/ts/interfaces/certmanager.ts new file mode 100644 index 0000000..55ac968 --- /dev/null +++ b/ts/interfaces/certmanager.ts @@ -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; + /** + * Initialize the store (e.g., connect to database). + */ + init(): Promise; + /** + * Retrieve a certificate record by domain name. + * Returns null if none found. + */ + retrieveCertificate(domainName: string): Promise; + /** + * Store a certificate record. Fulfills any pending interests. + */ + storeCertificate(cert: SmartacmeCert): Promise; + /** + * Delete a certificate record by domain name. + */ + deleteCertificate(domainName: string): Promise; + /** + * Close the store (e.g., disconnect database). + */ + close(): Promise; +} \ No newline at end of file diff --git a/ts/smartacme.classes.cert.ts b/ts/smartacme.classes.cert.ts index 3286cbe..06c93e4 100644 --- a/ts/smartacme.classes.cert.ts +++ b/ts/smartacme.classes.cert.ts @@ -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 - 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 = {}) { + 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; } } diff --git a/ts/smartacme.classes.certmanager.ts b/ts/smartacme.classes.certmanager.ts deleted file mode 100644 index 7738dde..0000000 --- a/ts/smartacme.classes.certmanager.ts +++ /dev/null @@ -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; - - 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 { - const existingCertificate: SmartacmeCert = await SmartacmeCert.getInstance({ - 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({ - domainName: certDomainNameArg, - }); - await cert.delete(); - } -} diff --git a/ts/smartacme.classes.smartacme.ts b/ts/smartacme.classes.smartacme.ts index 7fb8ff5..f1e74e6 100644 --- a/ts/smartacme.classes.smartacme.ts +++ b/ts/smartacme.classes.smartacme.ts @@ -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(operation: () => Promise, operationName: string = 'operation'): Promise { 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 { - return SmartacmeCert.getInstances({}); - } }