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.
This commit is contained in:
@@ -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,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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user