@push.rocks/smartacme 9.0.0 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,22 @@ import { SmartacmeCertMatcher } from './smartacme.classes.certmatcher.js';
4
4
  import { commitinfo } from './00_commitinfo_data.js';
5
5
  import { SmartacmeCert } from './smartacme.classes.cert.js';
6
6
 
7
+ // ── Types & constants for certificate issuance task ──────────────────────────
8
+
9
+ interface ICertIssuanceInput {
10
+ certDomainName: string;
11
+ domainArg: string;
12
+ isWildcardRequest: boolean;
13
+ includeWildcard: boolean;
14
+ }
15
+
16
+ const CERT_ISSUANCE_STEPS = [
17
+ { name: 'prepare', description: 'Creating ACME order', percentage: 10 },
18
+ { name: 'authorize', description: 'Solving ACME challenges', percentage: 40 },
19
+ { name: 'finalize', description: 'Finalizing and getting cert', percentage: 30 },
20
+ { name: 'store', description: 'Storing certificate', percentage: 20 },
21
+ ] as const;
22
+
7
23
  /**
8
24
  * the options for the class @see SmartAcme
9
25
  */
@@ -38,6 +54,21 @@ export interface ISmartAcmeOptions {
38
54
  * Defaults to ['dns-01'] or first supported type from handlers.
39
55
  */
40
56
  challengePriority?: string[];
57
+ /**
58
+ * Maximum number of concurrent ACME issuances across all domains.
59
+ * Defaults to 5.
60
+ */
61
+ maxConcurrentIssuances?: number;
62
+ /**
63
+ * Maximum ACME orders allowed within the sliding window.
64
+ * Defaults to 250 (conservative limit under Let's Encrypt's 300/3h).
65
+ */
66
+ maxOrdersPerWindow?: number;
67
+ /**
68
+ * Sliding window duration in milliseconds for rate limiting.
69
+ * Defaults to 3 hours (10_800_000 ms).
70
+ */
71
+ orderWindowMs?: number;
41
72
  }
42
73
 
43
74
  /**
@@ -75,12 +106,21 @@ export class SmartAcme {
75
106
  private pendingChallenges: plugins.tsclass.network.IDnsChallenge[] = [];
76
107
  // priority order of challenge types
77
108
  private challengePriority: string[];
78
- // Map for coordinating concurrent certificate requests
79
- private interestMap: plugins.lik.InterestMap<string, SmartacmeCert>;
109
+ // TaskManager for coordinating concurrent certificate requests
110
+ private taskManager: plugins.taskbuffer.TaskManager;
111
+ // Single reusable task for certificate issuance
112
+ private certIssuanceTask: plugins.taskbuffer.Task<undefined, typeof CERT_ISSUANCE_STEPS>;
80
113
  // bound signal handlers so they can be removed on stop()
81
114
  private boundSigintHandler: (() => void) | null = null;
82
115
  private boundSigtermHandler: (() => void) | null = null;
83
116
 
117
+ /**
118
+ * Exposes the aggregated task event stream for observing certificate issuance progress.
119
+ */
120
+ public get certIssuanceEvents(): plugins.taskbuffer.TaskManager['taskSubject'] {
121
+ return this.taskManager.taskSubject;
122
+ }
123
+
84
124
  constructor(optionsArg: ISmartAcmeOptions) {
85
125
  this.options = optionsArg;
86
126
  this.logger = plugins.smartlog.Smartlog.createForCommitinfo(commitinfo);
@@ -105,8 +145,60 @@ export class SmartAcme {
105
145
  optionsArg.challengePriority && optionsArg.challengePriority.length > 0
106
146
  ? optionsArg.challengePriority
107
147
  : this.challengeHandlers.map((h) => h.getSupportedTypes()[0]);
108
- // initialize interest coordination
109
- this.interestMap = new plugins.lik.InterestMap((domain) => domain);
148
+
149
+ // ── TaskManager setup ──────────────────────────────────────────────────
150
+ this.taskManager = new plugins.taskbuffer.TaskManager();
151
+
152
+ // Constraint 1: Per-domain mutex — one issuance at a time per TLD, with result sharing
153
+ const certDomainMutex = new plugins.taskbuffer.TaskConstraintGroup({
154
+ name: 'cert-domain-mutex',
155
+ maxConcurrent: 1,
156
+ resultSharingMode: 'share-latest',
157
+ constraintKeyForExecution: (_task, input?: ICertIssuanceInput) => {
158
+ return input?.certDomainName ?? null;
159
+ },
160
+ shouldExecute: async (_task, input?: ICertIssuanceInput) => {
161
+ if (!input?.certDomainName || !this.certmanager) return true;
162
+ // Safety net: if a valid cert is already cached, skip re-issuance
163
+ const existing = await this.certmanager.retrieveCertificate(input.certDomainName);
164
+ if (existing && !existing.shouldBeRenewed()) {
165
+ return false;
166
+ }
167
+ return true;
168
+ },
169
+ });
170
+
171
+ // Constraint 2: Global concurrency cap
172
+ const acmeGlobalConcurrency = new plugins.taskbuffer.TaskConstraintGroup({
173
+ name: 'acme-global-concurrency',
174
+ maxConcurrent: optionsArg.maxConcurrentIssuances ?? 5,
175
+ constraintKeyForExecution: () => 'global',
176
+ });
177
+
178
+ // Constraint 3: Account-level rate limiting
179
+ const acmeAccountRateLimit = new plugins.taskbuffer.TaskConstraintGroup({
180
+ name: 'acme-account-rate-limit',
181
+ rateLimit: {
182
+ maxPerWindow: optionsArg.maxOrdersPerWindow ?? 250,
183
+ windowMs: optionsArg.orderWindowMs ?? 10_800_000,
184
+ },
185
+ constraintKeyForExecution: () => 'account',
186
+ });
187
+
188
+ this.taskManager.addConstraintGroup(certDomainMutex);
189
+ this.taskManager.addConstraintGroup(acmeGlobalConcurrency);
190
+ this.taskManager.addConstraintGroup(acmeAccountRateLimit);
191
+
192
+ // Create the single reusable certificate issuance task
193
+ this.certIssuanceTask = new plugins.taskbuffer.Task({
194
+ name: 'cert-issuance',
195
+ steps: CERT_ISSUANCE_STEPS,
196
+ taskFunction: async (input: ICertIssuanceInput) => {
197
+ return this.performCertificateIssuance(input);
198
+ },
199
+ });
200
+
201
+ this.taskManager.addTask(this.certIssuanceTask);
110
202
  }
111
203
 
112
204
  /**
@@ -149,6 +241,10 @@ export class SmartAcme {
149
241
  termsOfServiceAgreed: true,
150
242
  contact: [`mailto:${this.options.accountEmail}`],
151
243
  });
244
+
245
+ // Start the task manager
246
+ await this.taskManager.start();
247
+
152
248
  // Setup graceful shutdown handlers (store references for removal in stop())
153
249
  this.boundSigintHandler = () => this.handleSignal('SIGINT');
154
250
  this.boundSigtermHandler = () => this.handleSignal('SIGTERM');
@@ -169,6 +265,16 @@ export class SmartAcme {
169
265
  process.removeListener('SIGTERM', this.boundSigtermHandler);
170
266
  this.boundSigtermHandler = null;
171
267
  }
268
+ // Stop the task manager
269
+ await this.taskManager.stop();
270
+ // Destroy ACME HTTP transport (closes keep-alive sockets)
271
+ if (this.client) {
272
+ this.client.destroy();
273
+ }
274
+ // Destroy DNS client (kills Rust bridge child process if spawned)
275
+ if (this.smartdns) {
276
+ this.smartdns.destroy();
277
+ }
172
278
  if (this.certmanager && typeof (this.certmanager as any).close === 'function') {
173
279
  await (this.certmanager as any).close();
174
280
  }
@@ -247,8 +353,7 @@ export class SmartAcme {
247
353
  * * if not in the database announce it
248
354
  * * then get it from letsencrypt
249
355
  * * store it
250
- * * remove it from the pending map (which it go onto by announcing it)
251
- * * retrieve it from the databse and return it
356
+ * * retrieve it from the database and return it
252
357
  *
253
358
  * @param domainArg
254
359
  * @param options Optional configuration for certificate generation
@@ -276,35 +381,59 @@ export class SmartAcme {
276
381
  // Retrieve any existing certificate record by base domain.
277
382
  const retrievedCertificate = await this.certmanager.retrieveCertificate(certDomainName);
278
383
 
279
- if (
280
- !retrievedCertificate &&
281
- (await this.interestMap.checkInterest(certDomainName))
282
- ) {
283
- const existingCertificateInterest = this.interestMap.findInterest(certDomainName);
284
- const certificate = existingCertificateInterest.interestFullfilled;
285
- return certificate;
286
- } else if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) {
384
+ if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) {
287
385
  return retrievedCertificate;
288
386
  } else if (retrievedCertificate && retrievedCertificate.shouldBeRenewed()) {
289
387
  // Remove old certificate via certManager
290
388
  await this.certmanager.deleteCertificate(certDomainName);
291
389
  }
292
390
 
293
- // lets make sure others get the same interest
294
- const currentDomainInterst = await this.interestMap.addInterest(certDomainName);
391
+ // Build issuance input and trigger the constrained task
392
+ const issuanceInput: ICertIssuanceInput = {
393
+ certDomainName,
394
+ domainArg,
395
+ isWildcardRequest,
396
+ includeWildcard: options?.includeWildcard ?? false,
397
+ };
398
+
399
+ const result = await this.taskManager.triggerTaskConstrained(
400
+ this.certIssuanceTask,
401
+ issuanceInput,
402
+ );
403
+
404
+ // If we got a cert directly (either from execution or result sharing), return it
405
+ if (result != null) {
406
+ return result;
407
+ }
408
+
409
+ // If shouldExecute returned false (cert appeared in cache), read from cache
410
+ const cachedCert = await this.certmanager.retrieveCertificate(certDomainName);
411
+ if (cachedCert) {
412
+ return cachedCert;
413
+ }
414
+
415
+ throw new Error(`Certificate issuance failed for ${certDomainName}`);
416
+ }
417
+
418
+ /**
419
+ * Performs the actual ACME certificate issuance flow.
420
+ * Called by the certIssuanceTask's taskFunction.
421
+ */
422
+ private async performCertificateIssuance(input: ICertIssuanceInput): Promise<SmartacmeCert> {
423
+ const { certDomainName, isWildcardRequest, includeWildcard } = input;
424
+
425
+ // ── Step: prepare ─────────────────────────────────────────────────────
426
+ this.certIssuanceTask.notifyStep('prepare');
295
427
 
296
428
  // Build identifiers array based on request
297
- const identifiers = [];
298
-
429
+ const identifiers: Array<{ type: 'dns'; value: string }> = [];
430
+
299
431
  if (isWildcardRequest) {
300
- // If requesting a wildcard directly, only add the wildcard
301
432
  identifiers.push({ type: 'dns', value: `*.${certDomainName}` });
302
433
  } else {
303
- // Add the regular domain
304
434
  identifiers.push({ type: 'dns', value: certDomainName });
305
-
306
- // Only add wildcard if explicitly requested
307
- if (options?.includeWildcard) {
435
+
436
+ if (includeWildcard) {
308
437
  const hasDnsHandler = this.challengeHandlers.some((h) =>
309
438
  h.getSupportedTypes().includes('dns-01'),
310
439
  );
@@ -321,6 +450,9 @@ export class SmartAcme {
321
450
  identifiers,
322
451
  }), 'createOrder');
323
452
 
453
+ // ── Step: authorize ───────────────────────────────────────────────────
454
+ this.certIssuanceTask.notifyStep('authorize');
455
+
324
456
  /* Get authorizations and select challenges */
325
457
  const authorizations = await this.retry(() => this.client.getAuthorizations(order), 'getAuthorizations');
326
458
 
@@ -344,45 +476,37 @@ export class SmartAcme {
344
476
  }
345
477
  const { type, handler } = selectedHandler;
346
478
  // build handler input with keyAuthorization
347
- let input: any;
479
+ let challengeInput: any;
348
480
  // retrieve keyAuthorization for challenge
349
481
  const keyAuth = await this.client.getChallengeKeyAuthorization(selectedChallengeArg);
350
482
  if (type === 'dns-01') {
351
- input = { type, hostName: `_acme-challenge.${authz.identifier.value}`, challenge: keyAuth };
483
+ challengeInput = { type, hostName: `_acme-challenge.${authz.identifier.value}`, challenge: keyAuth };
352
484
  } else if (type === 'http-01') {
353
- // HTTP-01 requires serving token at webPath
354
- input = {
485
+ challengeInput = {
355
486
  type,
356
487
  token: (selectedChallengeArg as any).token,
357
488
  keyAuthorization: keyAuth,
358
489
  webPath: `/.well-known/acme-challenge/${(selectedChallengeArg as any).token}`,
359
490
  };
360
491
  } else {
361
- // generic challenge input: include raw challenge properties
362
- input = { type, keyAuthorization: keyAuth, ...selectedChallengeArg };
492
+ challengeInput = { type, keyAuthorization: keyAuth, ...selectedChallengeArg };
363
493
  }
364
- this.pendingChallenges.push(input);
494
+ this.pendingChallenges.push(challengeInput);
365
495
  try {
366
- // Prepare the challenge (set DNS record, write file, etc.)
367
- await this.retry(() => handler.prepare(input), `${type}.prepare`);
368
- // For DNS-01, wait for propagation before verification
496
+ await this.retry(() => handler.prepare(challengeInput), `${type}.prepare`);
369
497
  if (type === 'dns-01') {
370
- const dnsInput = input as { hostName: string; challenge: string };
371
- // Wait for authoritative DNS propagation before ACME verify
498
+ const dnsInput = challengeInput as { hostName: string; challenge: string };
372
499
  await this.retry(
373
500
  () => this.smartdns.checkUntilAvailable(dnsInput.hostName, 'TXT', dnsInput.challenge, 100, 5000),
374
501
  `${type}.propagation`,
375
502
  );
376
- // Extra cool-down to ensure ACME server sees the new TXT record
377
503
  this.logger.log('info', 'Cooling down for 1 minute before ACME verification');
378
504
  await plugins.smartdelay.delayFor(60000);
379
505
  }
380
- // Notify ACME server to complete the challenge
381
506
  await this.retry(
382
507
  () => this.client.completeChallenge(selectedChallengeArg),
383
508
  `${type}.completeChallenge`,
384
509
  );
385
- // Wait for valid status (warnings on staging timeouts)
386
510
  try {
387
511
  await this.retry(
388
512
  () => this.client.waitForValidStatus(selectedChallengeArg),
@@ -396,34 +520,32 @@ export class SmartAcme {
396
520
  );
397
521
  }
398
522
  } finally {
399
- // Always cleanup resource
400
523
  try {
401
- await this.retry(() => handler.cleanup(input), `${type}.cleanup`);
524
+ await this.retry(() => handler.cleanup(challengeInput), `${type}.cleanup`);
402
525
  } catch (err) {
403
526
  await this.logger.log('error', `Error during ${type}.cleanup`, err);
404
527
  } finally {
405
- this.pendingChallenges = this.pendingChallenges.filter((c) => c !== input);
528
+ this.pendingChallenges = this.pendingChallenges.filter((c) => c !== challengeInput);
406
529
  }
407
530
  }
408
531
  }
409
532
 
410
- /* Finalize order */
411
- const csrDomains = [];
533
+ // ── Step: finalize ────────────────────────────────────────────────────
534
+ this.certIssuanceTask.notifyStep('finalize');
535
+
536
+ const csrDomains: string[] = [];
412
537
  let commonName: string;
413
-
538
+
414
539
  if (isWildcardRequest) {
415
- // For wildcard requests, use wildcard as common name
416
540
  commonName = `*.${certDomainName}`;
417
- csrDomains.push(certDomainName); // Add base domain as alt name
541
+ csrDomains.push(certDomainName);
418
542
  } else {
419
- // For regular requests, use base domain as common name
420
543
  commonName = certDomainName;
421
- if (options?.includeWildcard && identifiers.some(id => id.value === `*.${certDomainName}`)) {
422
- // If wildcard was successfully added, include it as alt name
544
+ if (includeWildcard && identifiers.some(id => id.value === `*.${certDomainName}`)) {
423
545
  csrDomains.push(`*.${certDomainName}`);
424
546
  }
425
547
  }
426
-
548
+
427
549
  const [key, csr] = await plugins.acme.AcmeCrypto.createCsr({
428
550
  commonName,
429
551
  altNames: csrDomains,
@@ -432,9 +554,9 @@ export class SmartAcme {
432
554
  await this.retry(() => this.client.finalizeOrder(order, csr), 'finalizeOrder');
433
555
  const cert = await this.retry(() => this.client.getCertificate(order), 'getCertificate');
434
556
 
435
- /* Done */
557
+ // ── Step: store ───────────────────────────────────────────────────────
558
+ this.certIssuanceTask.notifyStep('store');
436
559
 
437
- // Store the new certificate record
438
560
  const certRecord = new SmartacmeCert({
439
561
  id: plugins.smartunique.shortId(),
440
562
  domainName: certDomainName,
@@ -447,9 +569,7 @@ export class SmartAcme {
447
569
  await this.certmanager.storeCertificate(certRecord);
448
570
 
449
571
  const newCertificate = await this.certmanager.retrieveCertificate(certDomainName);
450
- currentDomainInterst.fullfillInterest(newCertificate);
451
- currentDomainInterst.destroy();
452
- return newCertificate;
572
+ return newCertificate ?? certRecord;
453
573
  }
454
574
 
455
575
  }