Compare commits

...

2 Commits

9 changed files with 258 additions and 90 deletions

View File

@@ -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).

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartacme",
"version": "9.0.1",
"version": "9.1.0",
"private": false,
"description": "A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.",
"main": "dist_ts/index.js",
@@ -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",

52
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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

View File

@@ -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;
@@ -48,7 +44,7 @@ tap.test('HTTP-01 only configuration should work for regular domains', async ()
getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
} as any;
smartAcmeInstance.retry = async (fn: () => Promise<any>) => fn();
(smartAcmeInstance as any).retry = async (fn: () => Promise<any>) => 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);
@@ -105,7 +97,7 @@ tap.test('should only include wildcard when explicitly requested with DNS-01', a
getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
} as any;
smartAcmeInstance.retry = async (fn: () => Promise<any>) => fn();
(smartAcmeInstance as any).retry = async (fn: () => Promise<any>) => fn();
// Mock certmanager methods
smartAcmeInstance.certmanager.retrieveCertificate = async () => null;
@@ -136,10 +128,6 @@ 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();
@@ -154,7 +142,7 @@ tap.test('should skip wildcard if requested but no DNS-01 handler available', as
};
// 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
@@ -167,7 +155,7 @@ tap.test('should skip wildcard if requested but no DNS-01 handler available', as
getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
} as any;
smartAcmeInstance.retry = async (fn: () => Promise<any>) => fn();
(smartAcmeInstance as any).retry = async (fn: () => Promise<any>) => fn();
// Mock certmanager methods
smartAcmeInstance.certmanager.retrieveCertificate = async () => null;

View File

@@ -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.'
}

View File

@@ -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';

View File

@@ -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

View File

@@ -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<string, SmartacmeCert>;
// TaskManager for coordinating concurrent certificate requests
private taskManager: plugins.taskbuffer.TaskManager;
// Single reusable task for certificate issuance
private certIssuanceTask: plugins.taskbuffer.Task<undefined, typeof CERT_ISSUANCE_STEPS>;
// 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<SmartacmeCert> {
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,30 +520,28 @@ 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}`);
}
}
@@ -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;
}
}