diff --git a/changelog.md b/changelog.md index 30d8ea5..9446c39 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-04-27 - 5.1.0 - feat(smartacme) +Implement exponential backoff retry logic and graceful shutdown handling in SmartAcme; update acme-client dependency to v5.4.0 + +- Added retry helper with exponential backoff for ACME client operations +- Introduced retryOptions in ISmartAcmeOptions for configurable retry parameters +- Enhanced graceful shutdown handling by cleaning up pending DNS challenges on signal +- Updated acme-client dependency from v4.2.5 to v5.4.0 + ## 2025-04-26 - 5.0.1 - fix(build) Update CI workflows, bump dependency versions, and refine import and TypeScript configuration diff --git a/package.json b/package.json index d078ef2..7d9e05e 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "@push.rocks/smarttime": "^4.1.1", "@push.rocks/smartunique": "^3.0.9", "@tsclass/tsclass": "^9.0.0", - "acme-client": "^4.2.5" + "acme-client": "^5.4.0" }, "devDependencies": { "@apiclient.xyz/cloudflare": "^6.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 308eb79..ca93254 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,8 +45,8 @@ importers: specifier: ^9.0.0 version: 9.0.0 acme-client: - specifier: ^4.2.5 - version: 4.2.5 + specifier: ^5.4.0 + version: 5.4.0 devDependencies: '@apiclient.xyz/cloudflare': specifier: ^6.3.2 @@ -1629,10 +1629,6 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} - acme-client@4.2.5: - resolution: {integrity: sha512-dtnck4sdZ2owFLTC73Ewjx0kmvsRjTRgaOc8UztCNODT+lr1DXj0tiuUXjeY4LAzZryXCtCib/E+KD8NYeP1aw==} - engines: {node: '>= 10'} - acme-client@5.4.0: resolution: {integrity: sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==} engines: {node: '>= 16'} @@ -1719,18 +1715,12 @@ packages: resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} engines: {node: '>=4'} - axios@0.26.1: - resolution: {integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==} - axios@1.9.0: resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==} b4a@1.6.7: resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} - backo2@1.0.2: - resolution: {integrity: sha1-MasayLEpNjRj41s+u2n038+6eUc=} - bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -1778,9 +1768,6 @@ packages: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} - bluebird@3.7.2: - resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} - bn.js@4.12.2: resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} @@ -2089,15 +2076,6 @@ packages: supports-color: optional: true - debug@4.3.4: - resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} engines: {node: '>=6.0'} @@ -2460,15 +2438,6 @@ packages: fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} - follow-redirects@1.15.5: - resolution: {integrity: sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - follow-redirects@1.15.9: resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} engines: {node: '>=4.0'} @@ -3411,9 +3380,6 @@ packages: ms@2.0.0: resolution: {integrity: sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=} - ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -7223,16 +7189,6 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 - acme-client@4.2.5: - dependencies: - axios: 0.26.1(debug@4.3.4) - backo2: 1.0.2 - bluebird: 3.7.2 - debug: 4.3.4 - node-forge: 1.3.1 - transitivePeerDependencies: - - supports-color - acme-client@5.4.0: dependencies: '@peculiar/x509': 1.12.3 @@ -7308,12 +7264,6 @@ snapshots: axe-core@4.10.3: {} - axios@0.26.1(debug@4.3.4): - dependencies: - follow-redirects: 1.15.5(debug@4.3.4) - transitivePeerDependencies: - - debug - axios@1.9.0(debug@4.4.0): dependencies: follow-redirects: 1.15.9(debug@4.4.0) @@ -7324,8 +7274,6 @@ snapshots: b4a@1.6.7: {} - backo2@1.0.2: {} - bail@2.0.2: {} balanced-match@1.0.2: {} @@ -7361,8 +7309,6 @@ snapshots: basic-ftp@5.0.5: {} - bluebird@3.7.2: {} - bn.js@4.12.2: {} body-parser@1.20.3: @@ -7681,10 +7627,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.3.4: - dependencies: - ms: 2.1.2 - debug@4.3.7: dependencies: ms: 2.1.3 @@ -8125,10 +8067,6 @@ snapshots: fn.name@1.1.0: {} - follow-redirects@1.15.5(debug@4.3.4): - optionalDependencies: - debug: 4.3.4 - follow-redirects@1.15.9(debug@4.4.0): optionalDependencies: debug: 4.4.0 @@ -9337,8 +9275,6 @@ snapshots: ms@2.0.0: {} - ms@2.1.2: {} - ms@2.1.3: {} nanocolors@0.2.13: {} diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 510237e..493111b 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: '5.0.1', + version: '5.1.0', description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.' } diff --git a/ts/smartacme.classes.smartacme.ts b/ts/smartacme.classes.smartacme.ts index 2680443..03186f9 100644 --- a/ts/smartacme.classes.smartacme.ts +++ b/ts/smartacme.classes.smartacme.ts @@ -14,6 +14,19 @@ export interface ISmartAcmeOptions { 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; + }; } /** @@ -30,7 +43,7 @@ export class SmartAcme { private options: ISmartAcmeOptions; // the acme client - private client: any; + private client: plugins.acme.Client; private smartdns = new plugins.smartdnsClient.Smartdns({}); public logger: plugins.smartlog.Smartlog; @@ -44,10 +57,23 @@ export class SmartAcme { // 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, + }; } /** @@ -88,11 +114,55 @@ export class SmartAcme { 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(); - } + 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 @@ -128,54 +198,54 @@ export class SmartAcme { // lets make sure others get the same interest const currentDomainInterst = await this.certmanager.interestMap.addInterest(certDomainName); - /* Place new order */ - const order = await this.client.createOrder({ + /* 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.client.getAuthorizations(order); + const authorizations = await this.retry(() => this.client.getAuthorizations(order), 'getAuthorizations'); for (const authz of authorizations) { - console.log(authz); + await this.logger.log('debug', 'Authorization received', authz); const fullHostName: string = `_acme-challenge.${authz.identifier.value}`; - const dnsChallenge: string = authz.challenges.find((challengeArg) => { + 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.setChallenge({ - hostName: fullHostName, - challenge: keyAuthorization, - }); + await this.retry(() => this.setChallenge(challengeRecord), 'setChallenge'); await plugins.smartdelay.delayFor(30000); - await this.smartdns.checkUntilAvailable(fullHostName, 'TXT', keyAuthorization, 100, 5000); - console.log('Cool down an extra 60 second for region availability'); + 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.client.verifyChallenge(authz, dnsChallenge); + await this.retry(() => this.client.verifyChallenge(authz, dnsChallenge), 'verifyChallenge'); /* Notify ACME provider that challenge is satisfied */ - await this.client.completeChallenge(dnsChallenge); + await this.retry(() => this.client.completeChallenge(dnsChallenge), 'completeChallenge'); /* Wait for ACME provider to respond with valid status */ - await this.client.waitForValidStatus(dnsChallenge); + await this.retry(() => this.client.waitForValidStatus(dnsChallenge), 'waitForValidStatus'); } finally { /* Clean up challenge response */ try { - await this.removeChallenge({ - hostName: fullHostName, - challenge: keyAuthorization, - }); - } catch (e) { - console.log(e); + 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); } } } @@ -186,8 +256,8 @@ export class SmartAcme { altNames: [certDomainName], }); - await this.client.finalizeOrder(order, csr); - const cert = await this.client.getCertificate(order); + await this.retry(() => this.client.finalizeOrder(order, csr), 'finalizeOrder'); + const cert = await this.retry(() => this.client.getCertificate(order), 'getCertificate'); /* Done */