From cfc0695c8af8f2204965208f7ad077dd6717a05c Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 15 Feb 2026 22:22:12 +0000 Subject: [PATCH] feat(smartacme): Integrate @push.rocks/taskbuffer TaskManager to coordinate ACME certificate issuance with per-domain mutex, global concurrency cap, and account-level rate limiting; refactor issuance flow into a single reusable cert-issuance task, expose issuance events, and update lifecycle to start/stop the TaskManager. Add configuration for concurrent issuances and sliding-window order limits, export taskbuffer types/plugins, and update tests and docs accordingly. --- changelog.md | 11 ++ package.json | 1 + pnpm-lock.yaml | 52 +++++-- readme.hints.md | 15 ++ test/test.http01-only.ts | 38 ++--- ts/00_commitinfo_data.ts | 2 +- ts/index.ts | 3 + ts/plugins.ts | 2 + ts/smartacme.classes.smartacme.ts | 222 ++++++++++++++++++++++-------- 9 files changed, 257 insertions(+), 89 deletions(-) diff --git a/changelog.md b/changelog.md index b9cc654..a3d05c9 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 2026-02-15 - 9.1.0 - feat(smartacme) +Integrate @push.rocks/taskbuffer TaskManager to coordinate ACME certificate issuance with per-domain mutex, global concurrency cap, and account-level rate limiting; refactor issuance flow into a single reusable cert-issuance task, expose issuance events, and update lifecycle to start/stop the TaskManager. Add configuration for concurrent issuances and sliding-window order limits, export taskbuffer types/plugins, and update tests and docs accordingly. + +- Added dependency @push.rocks/taskbuffer and re-exported ITaskEvent/ITaskMetadata in ts/index.ts; also imported/exported taskbuffer in ts/plugins.ts. +- Replaced interestMap coordination with TaskManager + TaskConstraintGroup(s): 'cert-domain-mutex' (per-domain mutex, resultSharingMode: 'share-latest'), 'acme-global-concurrency' (global concurrency cap), and 'acme-account-rate-limit' (sliding-window rate limiter). +- Introduced a single reusable Task named 'cert-issuance' and moved the ACME issuance flow into performCertificateIssuance(), splitting progress into named steps (prepare/authorize/finalize/store) and using notifyStep() for observable progress. +- Exposed certIssuanceEvents via SmartAcme.certIssuanceEvents and wired TaskManager.start()/stop() into SmartAcme.start()/stop(). +- Added new ISmartAcmeOptions: maxConcurrentIssuances, maxOrdersPerWindow, orderWindowMs to control concurrency and rate limiting. +- Updated tests to remove interestMap stubs and adapt to the taskbuffer-based flow; cleaned up client/retry stubbing in tests. +- Updated readme.hints.md with guidance on concurrency, rate limiting, and taskbuffer integration. + ## 2026-02-15 - 9.0.1 - fix(acme-http-client) Destroy keep-alive HTTP agents and DNS client on shutdown to allow process exit; add destroy() on AcmeHttpClient and AcmeClient, wire agents into requests, and call client/smartdns destroy during SmartAcme.stop; documentation clarifications and expanded README (error handling, examples, default retry values). diff --git a/package.json b/package.json index 249be6a..35d923f 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@peculiar/x509": "^1.14.3", "@push.rocks/lik": "^6.2.2", "@push.rocks/smartdata": "^7.0.15", + "@push.rocks/taskbuffer": "^6.1.0", "@push.rocks/smartdelay": "^3.0.5", "@push.rocks/smartdns": "^7.8.1", "@push.rocks/smartlog": "^3.1.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0aea52..576eee4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@push.rocks/smartunique': specifier: ^3.0.9 version: 3.0.9 + '@push.rocks/taskbuffer': + specifier: ^6.1.0 + version: 6.1.1 '@tsclass/tsclass': specifier: ^9.3.0 version: 9.3.0 @@ -1033,12 +1036,12 @@ packages: '@push.rocks/smartyaml@3.0.4': resolution: {integrity: sha512-1JRt+hnoc2zHw3AW+vXKlCdSVwqOmY/01fu+2HBviS0UDjoZCa+/rp6E3GaQb5lEEafKi8ENbffAfjXXp3N2xQ==} - '@push.rocks/taskbuffer@3.1.7': - resolution: {integrity: sha512-QktGVJPucqQmW/QNGnscf4FAigT1H7JWKFGFdRuDEaOHKFh9qN+PXG3QY7DtZ4jfXdGLxPN4yAufDuPSAJYFnw==} - '@push.rocks/taskbuffer@3.5.0': resolution: {integrity: sha512-Y9WwIEIyp6oVFdj06j84tfrZIvjhbMb3DF52rYxlTeYLk3W7RPhSg1bGPCbtkXWeKdBrSe37V90BkOG7Qq8Pqg==} + '@push.rocks/taskbuffer@6.1.1': + resolution: {integrity: sha512-rEJxf+yIbHwztNkrL5QJFinf0wai1Fzs1xgonEOo9LmG/DDCanfLWHSd5zCVG0kXxzz4sHv87fgkg+w/TIHLpg==} + '@push.rocks/webrequest@3.0.37': resolution: {integrity: sha512-fLN7kP6GeHFxE4UH4r9C9pjcQb0QkJxHeAMwXvbOqB9hh0MFNKhtGU7GoaTn8SVRGRMPc9UqZVNwo6u5l8Wn0A==} @@ -5533,8 +5536,11 @@ snapshots: '@push.rocks/smartpromise': 4.2.3 typescript: 5.9.3 transitivePeerDependencies: + - '@nuxt/kit' - aws-crt + - react - supports-color + - vue '@git.zone/tsbundle@2.8.3': dependencies: @@ -5556,8 +5562,11 @@ snapshots: rolldown: 1.0.0-beta.52 typescript: 5.9.3 transitivePeerDependencies: + - '@nuxt/kit' - '@swc/helpers' + - react - supports-color + - vue '@git.zone/tspublish@1.11.0': dependencies: @@ -5573,8 +5582,11 @@ snapshots: '@push.rocks/smartrequest': 5.0.1 '@push.rocks/smartshell': 3.3.0 transitivePeerDependencies: + - '@nuxt/kit' - aws-crt + - react - supports-color + - vue '@git.zone/tsrun@2.0.1': dependencies: @@ -5945,10 +5957,14 @@ snapshots: '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartstring': 4.1.0 '@push.rocks/smartunique': 3.0.9 - '@push.rocks/taskbuffer': 3.1.7 + '@push.rocks/taskbuffer': 3.5.0 '@tsclass/tsclass': 4.4.4 transitivePeerDependencies: + - '@nuxt/kit' - aws-crt + - react + - supports-color + - vue '@push.rocks/lik@6.2.2': dependencies: @@ -5982,8 +5998,13 @@ snapshots: '@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartrx': 3.0.10 - '@push.rocks/taskbuffer': 3.1.7 + '@push.rocks/taskbuffer': 3.5.0 '@tsclass/tsclass': 9.3.0 + transitivePeerDependencies: + - '@nuxt/kit' + - react + - supports-color + - vue '@push.rocks/qenv@6.1.3': dependencies: @@ -6108,19 +6129,22 @@ snapshots: '@push.rocks/smartstring': 4.1.0 '@push.rocks/smarttime': 4.1.1 '@push.rocks/smartunique': 3.0.9 - '@push.rocks/taskbuffer': 3.1.7 + '@push.rocks/taskbuffer': 3.5.0 '@tsclass/tsclass': 8.2.1 mongodb: 6.16.0 transitivePeerDependencies: - '@aws-sdk/credential-providers' - '@mongodb-js/zstd' + - '@nuxt/kit' - aws-crt - gcp-metadata - kerberos - mongodb-client-encryption + - react - snappy - socks - supports-color + - vue '@push.rocks/smartdata@7.0.15': dependencies: @@ -6347,13 +6371,16 @@ snapshots: transitivePeerDependencies: - '@aws-sdk/credential-providers' - '@mongodb-js/zstd' + - '@nuxt/kit' - aws-crt - gcp-metadata - kerberos - mongodb-client-encryption + - react - snappy - socks - supports-color + - vue '@push.rocks/smartnetwork@3.0.2': dependencies: @@ -6389,8 +6416,11 @@ snapshots: '@push.rocks/smartversion': 3.0.5 package-json: 8.1.1 transitivePeerDependencies: + - '@nuxt/kit' - aws-crt + - react - supports-color + - vue '@push.rocks/smartntml@2.0.8': dependencies: @@ -6642,8 +6672,9 @@ snapshots: dependencies: yaml: 2.8.2 - '@push.rocks/taskbuffer@3.1.7': + '@push.rocks/taskbuffer@3.5.0': dependencies: + '@design.estate/dees-element': 2.1.6 '@push.rocks/lik': 6.2.2 '@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartlog': 3.1.11 @@ -6651,8 +6682,13 @@ snapshots: '@push.rocks/smartrx': 3.0.10 '@push.rocks/smarttime': 4.1.1 '@push.rocks/smartunique': 3.0.9 + transitivePeerDependencies: + - '@nuxt/kit' + - react + - supports-color + - vue - '@push.rocks/taskbuffer@3.5.0': + '@push.rocks/taskbuffer@6.1.1': dependencies: '@design.estate/dees-element': 2.1.6 '@push.rocks/lik': 6.2.2 diff --git a/readme.hints.md b/readme.hints.md index c4364b9..05c511a 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -28,6 +28,21 @@ Key files: Usage in `ts/plugins.ts`: `import * as acme from './acme/index.js'` (replaces `acme-client`) +## Concurrency & Rate Limiting (taskbuffer integration) + +As of v9.1.0, `@push.rocks/lik.InterestMap` was replaced with `@push.rocks/taskbuffer.TaskManager` for coordinating concurrent certificate requests. This provides: + +- **Per-domain mutex** (`cert-domain-mutex`): Only one ACME issuance per TLD at a time, with `resultSharingMode: 'share-latest'` so queued callers get the same result without re-issuing. +- **Global concurrency cap** (`acme-global-concurrency`): Limits total parallel ACME operations (default 5, configurable via `maxConcurrentIssuances`). +- **Account-level rate limiting** (`acme-account-rate-limit`): Sliding-window rate limit (default 250 orders per 3 hours, configurable via `maxOrdersPerWindow`/`orderWindowMs`) to stay under Let's Encrypt limits. +- **Step-based progress**: The cert issuance task uses `notifyStep()` for prepare/authorize/finalize/store phases, observable via `smartAcme.certIssuanceEvents`. + +Key implementation details: +- A single reusable `Task` named `cert-issuance` handles all domains via `triggerTaskConstrained()` with different inputs. +- The `shouldExecute` callback on the domain mutex checks the certmanager cache as a safety net. +- `TaskManager.start()` is called in `SmartAcme.start()` and `TaskManager.stop()` in `SmartAcme.stop()`. +- The "no cronjobs specified" log messages during tests come from taskbuffer's internal CronManager polling — harmless noise when no cron tasks are scheduled. + ## Dependency Notes - `acme-client` was replaced with custom implementation in `ts/acme/` + `@peculiar/x509` for CSR generation diff --git a/test/test.http01-only.ts b/test/test.http01-only.ts index 56eac23..457b7e6 100644 --- a/test/test.http01-only.ts +++ b/test/test.http01-only.ts @@ -25,16 +25,12 @@ tap.test('HTTP-01 only configuration should work for regular domains', async () smartAcmeInstance.certmatcher = { getCertificateDomainNameByDomainName: (domain: string) => domain.replace('*.', '') } as any; - smartAcmeInstance.interestMap = { - checkInterest: async () => false, - addInterest: async () => ({ interestFullfilled: new Promise(() => {}), fullfillInterest: () => {}, destroy: () => {} } as any) - } as any; await smartAcmeInstance.certmanager.init(); }; await smartAcmeInstance.start(); - + // Stub the core certificate methods to avoid actual ACME calls - smartAcmeInstance.client = { + (smartAcmeInstance as any).client = { createOrder: async (orderPayload: any) => { // Verify no wildcard is included in default request const identifiers = orderPayload.identifiers; @@ -47,8 +43,8 @@ tap.test('HTTP-01 only configuration should work for regular domains', async () finalizeOrder: async () => {}, getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----', } as any; - - smartAcmeInstance.retry = async (fn: () => Promise) => fn(); + + (smartAcmeInstance as any).retry = async (fn: () => Promise) => fn(); // Mock certmanager methods smartAcmeInstance.certmanager.retrieveCertificate = async () => null; @@ -83,16 +79,12 @@ tap.test('should only include wildcard when explicitly requested with DNS-01', a smartAcmeInstance.certmatcher = { getCertificateDomainNameByDomainName: (domain: string) => domain.replace('*.', '') } as any; - smartAcmeInstance.interestMap = { - checkInterest: async () => false, - addInterest: async () => ({ interestFullfilled: new Promise(() => {}), fullfillInterest: () => {}, destroy: () => {} } as any) - } as any; await smartAcmeInstance.certmanager.init(); }; await smartAcmeInstance.start(); - + // Stub the core certificate methods - smartAcmeInstance.client = { + (smartAcmeInstance as any).client = { createOrder: async (orderPayload: any) => { const identifiers = orderPayload.identifiers; expect(identifiers.length).toEqual(2); @@ -104,8 +96,8 @@ tap.test('should only include wildcard when explicitly requested with DNS-01', a finalizeOrder: async () => {}, getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----', } as any; - - smartAcmeInstance.retry = async (fn: () => Promise) => fn(); + + (smartAcmeInstance as any).retry = async (fn: () => Promise) => fn(); // Mock certmanager methods smartAcmeInstance.certmanager.retrieveCertificate = async () => null; @@ -136,14 +128,10 @@ tap.test('should skip wildcard if requested but no DNS-01 handler available', as smartAcmeInstance.certmatcher = { getCertificateDomainNameByDomainName: (domain: string) => domain.replace('*.', '') } as any; - smartAcmeInstance.interestMap = { - checkInterest: async () => false, - addInterest: async () => ({ interestFullfilled: new Promise(() => {}), fullfillInterest: () => {}, destroy: () => {} } as any) - } as any; await smartAcmeInstance.certmanager.init(); }; await smartAcmeInstance.start(); - + // Mock logger to capture warning const logSpy = { called: false, message: '' }; smartAcmeInstance.logger.log = async (level: string, message: string) => { @@ -152,9 +140,9 @@ tap.test('should skip wildcard if requested but no DNS-01 handler available', as logSpy.message = message; } }; - + // Stub the core certificate methods - smartAcmeInstance.client = { + (smartAcmeInstance as any).client = { createOrder: async (orderPayload: any) => { const identifiers = orderPayload.identifiers; // Should only have regular domain, no wildcard @@ -166,8 +154,8 @@ tap.test('should skip wildcard if requested but no DNS-01 handler available', as finalizeOrder: async () => {}, getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----', } as any; - - smartAcmeInstance.retry = async (fn: () => Promise) => fn(); + + (smartAcmeInstance as any).retry = async (fn: () => Promise) => fn(); // Mock certmanager methods smartAcmeInstance.certmanager.retrieveCertificate = async () => null; diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 0fb3bb9..a3310d0 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: '9.0.1', + version: '9.1.0', description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.' } diff --git a/ts/index.ts b/ts/index.ts index 8fa5cd3..b703077 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -9,3 +9,6 @@ export { certmanagers }; // handlers import * as handlers from './handlers/index.js'; export { handlers }; + +// re-export taskbuffer event types for consumers +export type { ITaskEvent, ITaskMetadata } from '@push.rocks/taskbuffer'; diff --git a/ts/plugins.ts b/ts/plugins.ts index f797668..e4b15b5 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -19,6 +19,7 @@ import * as smartnetwork from '@push.rocks/smartnetwork'; import * as smartunique from '@push.rocks/smartunique'; import * as smartstring from '@push.rocks/smartstring'; import * as smarttime from '@push.rocks/smarttime'; +import * as taskbuffer from '@push.rocks/taskbuffer'; export { lik, @@ -30,6 +31,7 @@ export { smartunique, smartstring, smarttime, + taskbuffer, }; // @tsclass scope diff --git a/ts/smartacme.classes.smartacme.ts b/ts/smartacme.classes.smartacme.ts index 9507c92..9f05567 100644 --- a/ts/smartacme.classes.smartacme.ts +++ b/ts/smartacme.classes.smartacme.ts @@ -4,6 +4,22 @@ import { SmartacmeCertMatcher } from './smartacme.classes.certmatcher.js'; import { commitinfo } from './00_commitinfo_data.js'; import { SmartacmeCert } from './smartacme.classes.cert.js'; +// ── Types & constants for certificate issuance task ────────────────────────── + +interface ICertIssuanceInput { + certDomainName: string; + domainArg: string; + isWildcardRequest: boolean; + includeWildcard: boolean; +} + +const CERT_ISSUANCE_STEPS = [ + { name: 'prepare', description: 'Creating ACME order', percentage: 10 }, + { name: 'authorize', description: 'Solving ACME challenges', percentage: 40 }, + { name: 'finalize', description: 'Finalizing and getting cert', percentage: 30 }, + { name: 'store', description: 'Storing certificate', percentage: 20 }, +] as const; + /** * the options for the class @see SmartAcme */ @@ -38,6 +54,21 @@ export interface ISmartAcmeOptions { * Defaults to ['dns-01'] or first supported type from handlers. */ challengePriority?: string[]; + /** + * Maximum number of concurrent ACME issuances across all domains. + * Defaults to 5. + */ + maxConcurrentIssuances?: number; + /** + * Maximum ACME orders allowed within the sliding window. + * Defaults to 250 (conservative limit under Let's Encrypt's 300/3h). + */ + maxOrdersPerWindow?: number; + /** + * Sliding window duration in milliseconds for rate limiting. + * Defaults to 3 hours (10_800_000 ms). + */ + orderWindowMs?: number; } /** @@ -75,12 +106,21 @@ export class SmartAcme { private pendingChallenges: plugins.tsclass.network.IDnsChallenge[] = []; // priority order of challenge types private challengePriority: string[]; - // Map for coordinating concurrent certificate requests - private interestMap: plugins.lik.InterestMap; + // TaskManager for coordinating concurrent certificate requests + private taskManager: plugins.taskbuffer.TaskManager; + // Single reusable task for certificate issuance + private certIssuanceTask: plugins.taskbuffer.Task; // bound signal handlers so they can be removed on stop() private boundSigintHandler: (() => void) | null = null; private boundSigtermHandler: (() => void) | null = null; + /** + * Exposes the aggregated task event stream for observing certificate issuance progress. + */ + public get certIssuanceEvents(): plugins.taskbuffer.TaskManager['taskSubject'] { + return this.taskManager.taskSubject; + } + constructor(optionsArg: ISmartAcmeOptions) { this.options = optionsArg; this.logger = plugins.smartlog.Smartlog.createForCommitinfo(commitinfo); @@ -105,8 +145,60 @@ export class SmartAcme { optionsArg.challengePriority && optionsArg.challengePriority.length > 0 ? optionsArg.challengePriority : this.challengeHandlers.map((h) => h.getSupportedTypes()[0]); - // initialize interest coordination - this.interestMap = new plugins.lik.InterestMap((domain) => domain); + + // ── TaskManager setup ────────────────────────────────────────────────── + this.taskManager = new plugins.taskbuffer.TaskManager(); + + // Constraint 1: Per-domain mutex — one issuance at a time per TLD, with result sharing + const certDomainMutex = new plugins.taskbuffer.TaskConstraintGroup({ + name: 'cert-domain-mutex', + maxConcurrent: 1, + resultSharingMode: 'share-latest', + constraintKeyForExecution: (_task, input?: ICertIssuanceInput) => { + return input?.certDomainName ?? null; + }, + shouldExecute: async (_task, input?: ICertIssuanceInput) => { + if (!input?.certDomainName || !this.certmanager) return true; + // Safety net: if a valid cert is already cached, skip re-issuance + const existing = await this.certmanager.retrieveCertificate(input.certDomainName); + if (existing && !existing.shouldBeRenewed()) { + return false; + } + return true; + }, + }); + + // Constraint 2: Global concurrency cap + const acmeGlobalConcurrency = new plugins.taskbuffer.TaskConstraintGroup({ + name: 'acme-global-concurrency', + maxConcurrent: optionsArg.maxConcurrentIssuances ?? 5, + constraintKeyForExecution: () => 'global', + }); + + // Constraint 3: Account-level rate limiting + const acmeAccountRateLimit = new plugins.taskbuffer.TaskConstraintGroup({ + name: 'acme-account-rate-limit', + rateLimit: { + maxPerWindow: optionsArg.maxOrdersPerWindow ?? 250, + windowMs: optionsArg.orderWindowMs ?? 10_800_000, + }, + constraintKeyForExecution: () => 'account', + }); + + this.taskManager.addConstraintGroup(certDomainMutex); + this.taskManager.addConstraintGroup(acmeGlobalConcurrency); + this.taskManager.addConstraintGroup(acmeAccountRateLimit); + + // Create the single reusable certificate issuance task + this.certIssuanceTask = new plugins.taskbuffer.Task({ + name: 'cert-issuance', + steps: CERT_ISSUANCE_STEPS, + taskFunction: async (input: ICertIssuanceInput) => { + return this.performCertificateIssuance(input); + }, + }); + + this.taskManager.addTask(this.certIssuanceTask); } /** @@ -149,6 +241,10 @@ export class SmartAcme { termsOfServiceAgreed: true, contact: [`mailto:${this.options.accountEmail}`], }); + + // Start the task manager + await this.taskManager.start(); + // Setup graceful shutdown handlers (store references for removal in stop()) this.boundSigintHandler = () => this.handleSignal('SIGINT'); this.boundSigtermHandler = () => this.handleSignal('SIGTERM'); @@ -169,6 +265,8 @@ export class SmartAcme { process.removeListener('SIGTERM', this.boundSigtermHandler); this.boundSigtermHandler = null; } + // Stop the task manager + await this.taskManager.stop(); // Destroy ACME HTTP transport (closes keep-alive sockets) if (this.client) { this.client.destroy(); @@ -255,8 +353,7 @@ export class SmartAcme { * * if not in the database announce it * * then get it from letsencrypt * * store it - * * remove it from the pending map (which it go onto by announcing it) - * * retrieve it from the databse and return it + * * retrieve it from the database and return it * * @param domainArg * @param options Optional configuration for certificate generation @@ -284,35 +381,59 @@ export class SmartAcme { // Retrieve any existing certificate record by base domain. const retrievedCertificate = await this.certmanager.retrieveCertificate(certDomainName); - if ( - !retrievedCertificate && - (await this.interestMap.checkInterest(certDomainName)) - ) { - const existingCertificateInterest = this.interestMap.findInterest(certDomainName); - const certificate = existingCertificateInterest.interestFullfilled; - return certificate; - } else if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) { + if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) { return retrievedCertificate; } else if (retrievedCertificate && retrievedCertificate.shouldBeRenewed()) { // Remove old certificate via certManager await this.certmanager.deleteCertificate(certDomainName); } - // lets make sure others get the same interest - const currentDomainInterst = await this.interestMap.addInterest(certDomainName); + // Build issuance input and trigger the constrained task + const issuanceInput: ICertIssuanceInput = { + certDomainName, + domainArg, + isWildcardRequest, + includeWildcard: options?.includeWildcard ?? false, + }; + + const result = await this.taskManager.triggerTaskConstrained( + this.certIssuanceTask, + issuanceInput, + ); + + // If we got a cert directly (either from execution or result sharing), return it + if (result != null) { + return result; + } + + // If shouldExecute returned false (cert appeared in cache), read from cache + const cachedCert = await this.certmanager.retrieveCertificate(certDomainName); + if (cachedCert) { + return cachedCert; + } + + throw new Error(`Certificate issuance failed for ${certDomainName}`); + } + + /** + * Performs the actual ACME certificate issuance flow. + * Called by the certIssuanceTask's taskFunction. + */ + private async performCertificateIssuance(input: ICertIssuanceInput): Promise { + const { certDomainName, isWildcardRequest, includeWildcard } = input; + + // ── Step: prepare ───────────────────────────────────────────────────── + this.certIssuanceTask.notifyStep('prepare'); // Build identifiers array based on request - const identifiers = []; - + const identifiers: Array<{ type: 'dns'; value: string }> = []; + if (isWildcardRequest) { - // If requesting a wildcard directly, only add the wildcard identifiers.push({ type: 'dns', value: `*.${certDomainName}` }); } else { - // Add the regular domain identifiers.push({ type: 'dns', value: certDomainName }); - - // Only add wildcard if explicitly requested - if (options?.includeWildcard) { + + if (includeWildcard) { const hasDnsHandler = this.challengeHandlers.some((h) => h.getSupportedTypes().includes('dns-01'), ); @@ -329,6 +450,9 @@ export class SmartAcme { identifiers, }), 'createOrder'); + // ── Step: authorize ─────────────────────────────────────────────────── + this.certIssuanceTask.notifyStep('authorize'); + /* Get authorizations and select challenges */ const authorizations = await this.retry(() => this.client.getAuthorizations(order), 'getAuthorizations'); @@ -352,45 +476,37 @@ export class SmartAcme { } const { type, handler } = selectedHandler; // build handler input with keyAuthorization - let input: any; + let challengeInput: any; // retrieve keyAuthorization for challenge const keyAuth = await this.client.getChallengeKeyAuthorization(selectedChallengeArg); if (type === 'dns-01') { - input = { type, hostName: `_acme-challenge.${authz.identifier.value}`, challenge: keyAuth }; + challengeInput = { type, hostName: `_acme-challenge.${authz.identifier.value}`, challenge: keyAuth }; } else if (type === 'http-01') { - // HTTP-01 requires serving token at webPath - input = { + challengeInput = { type, token: (selectedChallengeArg as any).token, keyAuthorization: keyAuth, webPath: `/.well-known/acme-challenge/${(selectedChallengeArg as any).token}`, }; } else { - // generic challenge input: include raw challenge properties - input = { type, keyAuthorization: keyAuth, ...selectedChallengeArg }; + challengeInput = { type, keyAuthorization: keyAuth, ...selectedChallengeArg }; } - this.pendingChallenges.push(input); + this.pendingChallenges.push(challengeInput); try { - // Prepare the challenge (set DNS record, write file, etc.) - await this.retry(() => handler.prepare(input), `${type}.prepare`); - // For DNS-01, wait for propagation before verification + await this.retry(() => handler.prepare(challengeInput), `${type}.prepare`); if (type === 'dns-01') { - const dnsInput = input as { hostName: string; challenge: string }; - // Wait for authoritative DNS propagation before ACME verify + const dnsInput = challengeInput as { hostName: string; challenge: string }; 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); } - // 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), @@ -404,34 +520,32 @@ export class SmartAcme { ); } } finally { - // Always cleanup resource try { - await this.retry(() => handler.cleanup(input), `${type}.cleanup`); + await this.retry(() => handler.cleanup(challengeInput), `${type}.cleanup`); } catch (err) { await this.logger.log('error', `Error during ${type}.cleanup`, err); } finally { - this.pendingChallenges = this.pendingChallenges.filter((c) => c !== input); + this.pendingChallenges = this.pendingChallenges.filter((c) => c !== challengeInput); } } } - /* Finalize order */ - const csrDomains = []; + // ── Step: finalize ──────────────────────────────────────────────────── + this.certIssuanceTask.notifyStep('finalize'); + + const csrDomains: string[] = []; let commonName: string; - + if (isWildcardRequest) { - // For wildcard requests, use wildcard as common name commonName = `*.${certDomainName}`; - csrDomains.push(certDomainName); // Add base domain as alt name + csrDomains.push(certDomainName); } else { - // For regular requests, use base domain as common name commonName = certDomainName; - if (options?.includeWildcard && identifiers.some(id => id.value === `*.${certDomainName}`)) { - // If wildcard was successfully added, include it as alt name + if (includeWildcard && identifiers.some(id => id.value === `*.${certDomainName}`)) { csrDomains.push(`*.${certDomainName}`); } } - + const [key, csr] = await plugins.acme.AcmeCrypto.createCsr({ commonName, altNames: csrDomains, @@ -440,9 +554,9 @@ export class SmartAcme { await this.retry(() => this.client.finalizeOrder(order, csr), 'finalizeOrder'); const cert = await this.retry(() => this.client.getCertificate(order), 'getCertificate'); - /* Done */ + // ── Step: store ─────────────────────────────────────────────────────── + this.certIssuanceTask.notifyStep('store'); - // Store the new certificate record const certRecord = new SmartacmeCert({ id: plugins.smartunique.shortId(), domainName: certDomainName, @@ -455,9 +569,7 @@ export class SmartAcme { await this.certmanager.storeCertificate(certRecord); const newCertificate = await this.certmanager.retrieveCertificate(certDomainName); - currentDomainInterst.fullfillInterest(newCertificate); - currentDomainInterst.destroy(); - return newCertificate; + return newCertificate ?? certRecord; } }