@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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/acme/acme.classes.client.d.ts +4 -0
- package/dist_ts/acme/acme.classes.client.js +7 -1
- package/dist_ts/acme/acme.classes.http-client.d.ts +6 -0
- package/dist_ts/acme/acme.classes.http-client.js +11 -1
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/dist_ts/smartacme.classes.smartacme.d.ts +27 -3
- package/dist_ts/smartacme.classes.smartacme.js +121 -47
- package/package.json +2 -1
- package/readme.hints.md +15 -0
- package/readme.md +77 -37
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/acme/acme.classes.client.ts +7 -0
- package/ts/acme/acme.classes.http-client.ts +13 -0
- package/ts/index.ts +3 -0
- package/ts/plugins.ts +2 -0
- package/ts/smartacme.classes.smartacme.ts +175 -55
|
@@ -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
|
-
//
|
|
79
|
-
private
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
* *
|
|
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
|
-
//
|
|
294
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
483
|
+
challengeInput = { type, hostName: `_acme-challenge.${authz.identifier.value}`, challenge: keyAuth };
|
|
352
484
|
} else if (type === 'http-01') {
|
|
353
|
-
|
|
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
|
-
|
|
362
|
-
input = { type, keyAuthorization: keyAuth, ...selectedChallengeArg };
|
|
492
|
+
challengeInput = { type, keyAuthorization: keyAuth, ...selectedChallengeArg };
|
|
363
493
|
}
|
|
364
|
-
this.pendingChallenges.push(
|
|
494
|
+
this.pendingChallenges.push(challengeInput);
|
|
365
495
|
try {
|
|
366
|
-
|
|
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 =
|
|
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(
|
|
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 !==
|
|
528
|
+
this.pendingChallenges = this.pendingChallenges.filter((c) => c !== challengeInput);
|
|
406
529
|
}
|
|
407
530
|
}
|
|
408
531
|
}
|
|
409
532
|
|
|
410
|
-
|
|
411
|
-
|
|
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);
|
|
541
|
+
csrDomains.push(certDomainName);
|
|
418
542
|
} else {
|
|
419
|
-
// For regular requests, use base domain as common name
|
|
420
543
|
commonName = certDomainName;
|
|
421
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
451
|
-
currentDomainInterst.destroy();
|
|
452
|
-
return newCertificate;
|
|
572
|
+
return newCertificate ?? certRecord;
|
|
453
573
|
}
|
|
454
574
|
|
|
455
575
|
}
|