@push.rocks/smartacme 9.5.0 → 9.7.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/.smartconfig.json +12 -7
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/acme/acme.classes.client.d.ts +2 -0
- package/dist_ts/acme/acme.classes.client.js +5 -2
- package/dist_ts/acme/acme.classes.http-client.d.ts +2 -1
- package/dist_ts/acme/acme.classes.http-client.js +4 -2
- package/dist_ts/acme/acme.classes.order.d.ts +3 -0
- package/dist_ts/acme/acme.classes.order.js +11 -2
- package/dist_ts/classes.exact-certificate-issuer.d.ts +37 -0
- package/dist_ts/classes.exact-certificate-issuer.js +397 -0
- package/dist_ts/handlers/IChallengeHandler.d.ts +8 -3
- package/dist_ts/handlers/index.d.ts +1 -1
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/interfaces/exact-issuance.d.ts +79 -0
- package/dist_ts/interfaces/exact-issuance.js +2 -0
- package/dist_ts/plugins.d.ts +5 -1
- package/dist_ts/plugins.js +6 -2
- package/dist_ts/smartacme.classes.smartacme.d.ts +22 -0
- package/dist_ts/smartacme.classes.smartacme.js +141 -16
- package/package.json +11 -15
- package/readme.md +90 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/acme/acme.classes.client.ts +6 -1
- package/ts/acme/acme.classes.http-client.ts +2 -1
- package/ts/acme/acme.classes.order.ts +12 -1
- package/ts/classes.exact-certificate-issuer.ts +408 -0
- package/ts/handlers/IChallengeHandler.ts +10 -4
- package/ts/handlers/index.ts +2 -2
- package/ts/index.ts +2 -0
- package/ts/interfaces/exact-issuance.ts +104 -0
- package/ts/plugins.ts +5 -1
- package/ts/smartacme.classes.smartacme.ts +151 -15
- package/readme.hints.md +0 -81
- package/readme.plan.md +0 -3
|
@@ -3,6 +3,12 @@ import type { ICertManager } from './interfaces/certmanager.js';
|
|
|
3
3
|
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
|
+
import { ExactCertificateIssuer } from './classes.exact-certificate-issuer.js';
|
|
7
|
+
import type {
|
|
8
|
+
IExactCertificateIdentity,
|
|
9
|
+
IExactCertificateRequest, IExactIssuanceRecoveryRequest, IExactIssuanceStore,
|
|
10
|
+
IExactIssuanceInfo, TExactCertificateResult,
|
|
11
|
+
} from './interfaces/exact-issuance.js';
|
|
6
12
|
|
|
7
13
|
// ── Types & constants for certificate issuance task ──────────────────────────
|
|
8
14
|
|
|
@@ -12,6 +18,7 @@ interface ICertIssuanceInput {
|
|
|
12
18
|
isWildcardRequest: boolean;
|
|
13
19
|
includeWildcard: boolean;
|
|
14
20
|
forceRenew: boolean;
|
|
21
|
+
exact?: { request: IExactCertificateRequest; recovery?: IExactIssuanceRecoveryRequest };
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
const CERT_ISSUANCE_STEPS = [
|
|
@@ -33,6 +40,14 @@ export interface ISmartAcmeOptions {
|
|
|
33
40
|
certManager: ICertManager;
|
|
34
41
|
// Removed legacy setChallenge/removeChallenge in favor of `challengeHandlers`
|
|
35
42
|
environment: 'production' | 'integration';
|
|
43
|
+
/** Optional explicit ACME directory, for private CAs and isolated integration. */
|
|
44
|
+
directoryUrl?: string;
|
|
45
|
+
/** Enables isolated explicit-identifier issuance with durable recovery. */
|
|
46
|
+
exactIssuanceStore?: IExactIssuanceStore;
|
|
47
|
+
/** Bounded number of admitted exact-identity operations (default 64). */
|
|
48
|
+
maxPendingExactRequests?: number;
|
|
49
|
+
/** DNS propagation grace period for explicit issuance; defaults to 60 seconds. */
|
|
50
|
+
exactDnsPropagationDelayMs?: number;
|
|
36
51
|
/**
|
|
37
52
|
* Optional retry/backoff configuration for transient failures
|
|
38
53
|
*/
|
|
@@ -92,6 +107,10 @@ export class SmartAcme {
|
|
|
92
107
|
|
|
93
108
|
// the account private key
|
|
94
109
|
private privateKey!: string;
|
|
110
|
+
private exactIssuer?: ExactCertificateIssuer;
|
|
111
|
+
private exactRequests = new Map<string, Promise<TExactCertificateResult>>();
|
|
112
|
+
private lifecycleAbort = new AbortController();
|
|
113
|
+
private started = false;
|
|
95
114
|
|
|
96
115
|
|
|
97
116
|
// certificate manager for persistence (implements ICertManager)
|
|
@@ -104,7 +123,7 @@ export class SmartAcme {
|
|
|
104
123
|
// retry/backoff configuration (resolved with defaults)
|
|
105
124
|
private retryOptions: { retries: number; factor: number; minTimeoutMs: number; maxTimeoutMs: number };
|
|
106
125
|
// track pending DNS challenges for graceful shutdown
|
|
107
|
-
private pendingChallenges: plugins.
|
|
126
|
+
private pendingChallenges: { input: any; context: plugins.handlers.IChallengeContext }[] = [];
|
|
108
127
|
// priority order of challenge types
|
|
109
128
|
private challengePriority: string[];
|
|
110
129
|
// TaskManager for coordinating concurrent certificate requests
|
|
@@ -124,6 +143,12 @@ export class SmartAcme {
|
|
|
124
143
|
|
|
125
144
|
constructor(optionsArg: ISmartAcmeOptions) {
|
|
126
145
|
this.options = optionsArg;
|
|
146
|
+
if (!Number.isSafeInteger(optionsArg.maxPendingExactRequests ?? 64)
|
|
147
|
+
|| (optionsArg.maxPendingExactRequests ?? 64) < 1
|
|
148
|
+
|| !Number.isSafeInteger(optionsArg.exactDnsPropagationDelayMs ?? 60000)
|
|
149
|
+
|| (optionsArg.exactDnsPropagationDelayMs ?? 60000) < 0) {
|
|
150
|
+
throw new Error('Invalid explicit issuance admission or propagation limits');
|
|
151
|
+
}
|
|
127
152
|
this.logger = plugins.smartlog.Smartlog.createForCommitinfo(commitinfo);
|
|
128
153
|
// enable console output for structured logging
|
|
129
154
|
this.logger.enableConsole();
|
|
@@ -156,9 +181,11 @@ export class SmartAcme {
|
|
|
156
181
|
maxConcurrent: 1,
|
|
157
182
|
resultSharingMode: 'share-latest',
|
|
158
183
|
constraintKeyForExecution: (_task, input?: ICertIssuanceInput) => {
|
|
184
|
+
if (input?.exact) return null;
|
|
159
185
|
return input?.certDomainName ?? null;
|
|
160
186
|
},
|
|
161
187
|
shouldExecute: async (_task, input?: ICertIssuanceInput) => {
|
|
188
|
+
if (input?.exact) return true;
|
|
162
189
|
if (!input?.certDomainName || !this.certmanager) return true;
|
|
163
190
|
if (input.forceRenew) return true;
|
|
164
191
|
// Safety net: if a valid cert is already cached, skip re-issuance
|
|
@@ -188,6 +215,12 @@ export class SmartAcme {
|
|
|
188
215
|
});
|
|
189
216
|
|
|
190
217
|
this.taskManager.addConstraintGroup(certDomainMutex);
|
|
218
|
+
// One fixed key reserves a bounded share of global capacity for testing.
|
|
219
|
+
// Exact results contain private keys and must never enter result-sharing caches.
|
|
220
|
+
this.taskManager.addConstraintGroup(new plugins.taskbuffer.TaskConstraintGroup({
|
|
221
|
+
name: 'exact-issuance-concurrency', maxConcurrent: 2,
|
|
222
|
+
constraintKeyForExecution: (_task, input?: ICertIssuanceInput) => input?.exact ? 'exact' : null,
|
|
223
|
+
}));
|
|
191
224
|
this.taskManager.addConstraintGroup(acmeGlobalConcurrency);
|
|
192
225
|
this.taskManager.addConstraintGroup(acmeAccountRateLimit);
|
|
193
226
|
|
|
@@ -196,6 +229,12 @@ export class SmartAcme {
|
|
|
196
229
|
name: 'cert-issuance',
|
|
197
230
|
steps: CERT_ISSUANCE_STEPS,
|
|
198
231
|
taskFunction: async (input: ICertIssuanceInput) => {
|
|
232
|
+
if (input.exact) {
|
|
233
|
+
const issuer = this.requireExactIssuer();
|
|
234
|
+
return input.exact.recovery
|
|
235
|
+
? issuer.recover(input.exact.recovery)
|
|
236
|
+
: issuer.run(input.exact.request);
|
|
237
|
+
}
|
|
199
238
|
return this.performCertificateIssuance(input);
|
|
200
239
|
},
|
|
201
240
|
});
|
|
@@ -210,8 +249,31 @@ export class SmartAcme {
|
|
|
210
249
|
* ```
|
|
211
250
|
*/
|
|
212
251
|
public async start() {
|
|
252
|
+
if (this.started) throw new Error('SmartAcme is already started');
|
|
253
|
+
this.lifecycleAbort = new AbortController();
|
|
213
254
|
this.privateKey =
|
|
214
255
|
this.options.accountPrivateKey || plugins.acme.AcmeCrypto.createRsaPrivateKey();
|
|
256
|
+
const directoryUrl = this.options.directoryUrl ?? (this.options.environment === 'production'
|
|
257
|
+
? plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.production
|
|
258
|
+
: plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.staging);
|
|
259
|
+
const directory = new URL(directoryUrl);
|
|
260
|
+
if (directory.username || directory.password || directory.hash
|
|
261
|
+
|| (directory.protocol !== 'https:' && !(directory.protocol === 'http:'
|
|
262
|
+
&& ['localhost', '127.0.0.1', '[::1]'].includes(directory.hostname)))) {
|
|
263
|
+
throw new Error('An HTTPS ACME directory or an explicit loopback test directory is required');
|
|
264
|
+
}
|
|
265
|
+
if (this.options.exactIssuanceStore && !this.options.accountPrivateKey) {
|
|
266
|
+
const accountId = plugins.crypto.createHash('sha256')
|
|
267
|
+
.update(JSON.stringify([directoryUrl, this.options.accountEmail.trim().toLowerCase()])).digest('hex');
|
|
268
|
+
const store = this.options.exactIssuanceStore;
|
|
269
|
+
let storedKey = await store.getAccountKey(accountId);
|
|
270
|
+
if (!storedKey) {
|
|
271
|
+
await store.createAccountKey(accountId, this.privateKey);
|
|
272
|
+
storedKey = await store.getAccountKey(accountId);
|
|
273
|
+
}
|
|
274
|
+
if (!storedKey) throw new Error('ACME account key could not be retained durably');
|
|
275
|
+
this.privateKey = storedKey;
|
|
276
|
+
}
|
|
215
277
|
|
|
216
278
|
// Initialize certificate manager
|
|
217
279
|
if (!this.options.certManager) {
|
|
@@ -225,14 +287,9 @@ export class SmartAcme {
|
|
|
225
287
|
|
|
226
288
|
// ACME Client
|
|
227
289
|
this.client = new plugins.acme.AcmeClient({
|
|
228
|
-
directoryUrl
|
|
229
|
-
if (this.options.environment === 'production') {
|
|
230
|
-
return plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.production;
|
|
231
|
-
} else {
|
|
232
|
-
return plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.staging;
|
|
233
|
-
}
|
|
234
|
-
})(),
|
|
290
|
+
directoryUrl,
|
|
235
291
|
accountKeyPem: this.privateKey,
|
|
292
|
+
signal: this.lifecycleAbort.signal,
|
|
236
293
|
logger: (level, message, data) => {
|
|
237
294
|
this.logger.log(level as any, message, data);
|
|
238
295
|
},
|
|
@@ -246,6 +303,15 @@ export class SmartAcme {
|
|
|
246
303
|
|
|
247
304
|
// Start the task manager
|
|
248
305
|
await this.taskManager.start();
|
|
306
|
+
if (this.options.exactIssuanceStore) {
|
|
307
|
+
this.exactIssuer = new ExactCertificateIssuer({
|
|
308
|
+
store: this.options.exactIssuanceStore, client: this.client, directoryUrl,
|
|
309
|
+
accountThumbprint: plugins.acme.AcmeCrypto.getJwkThumbprint(plugins.acme.AcmeCrypto.getJwk(this.privateKey)),
|
|
310
|
+
dns: this.smartdns, handlers: this.challengeHandlers, signal: this.lifecycleAbort.signal,
|
|
311
|
+
dnsPropagationDelayMs: this.options.exactDnsPropagationDelayMs ?? 60000,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
this.started = true;
|
|
249
315
|
|
|
250
316
|
// Setup graceful shutdown handlers (store references for removal in stop())
|
|
251
317
|
this.boundSigintHandler = () => this.handleSignal('SIGINT');
|
|
@@ -258,6 +324,8 @@ export class SmartAcme {
|
|
|
258
324
|
* Stops the SmartAcme instance and closes certificate store connections.
|
|
259
325
|
*/
|
|
260
326
|
public async stop() {
|
|
327
|
+
this.started = false;
|
|
328
|
+
this.lifecycleAbort.abort();
|
|
261
329
|
// Remove signal handlers so the process can exit cleanly
|
|
262
330
|
if (this.boundSigintHandler) {
|
|
263
331
|
process.removeListener('SIGINT', this.boundSigintHandler);
|
|
@@ -267,8 +335,13 @@ export class SmartAcme {
|
|
|
267
335
|
process.removeListener('SIGTERM', this.boundSigtermHandler);
|
|
268
336
|
this.boundSigtermHandler = null;
|
|
269
337
|
}
|
|
270
|
-
//
|
|
338
|
+
// Observe active requests before rejecting queued work. The task manager
|
|
339
|
+
// must close its queue before we drain callers waiting on that queue.
|
|
340
|
+
const pendingExactRequests = Promise.allSettled([...this.exactRequests.values()]);
|
|
271
341
|
await this.taskManager.stop();
|
|
342
|
+
await pendingExactRequests;
|
|
343
|
+
this.exactRequests.clear();
|
|
344
|
+
this.exactIssuer = undefined;
|
|
272
345
|
// Destroy ACME HTTP transport (closes keep-alive sockets)
|
|
273
346
|
if (this.client) {
|
|
274
347
|
this.client.destroy();
|
|
@@ -281,6 +354,64 @@ export class SmartAcme {
|
|
|
281
354
|
await (this.certmanager as any).close();
|
|
282
355
|
}
|
|
283
356
|
}
|
|
357
|
+
|
|
358
|
+
/** Bind durable work to the current account and issuer without creating an order. */
|
|
359
|
+
public getCertificateIdentity(request: IExactCertificateRequest): IExactCertificateIdentity {
|
|
360
|
+
return this.requireExactIssuer().identity(request);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Ensure a certificate whose SANs exactly match the explicit identifiers. */
|
|
364
|
+
public async getCertificateForIdentifiers(request: IExactCertificateRequest): Promise<TExactCertificateResult> {
|
|
365
|
+
return await this.runExact(request);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
public async getCertificateIssuanceStatus(request: IExactCertificateRequest): Promise<IExactIssuanceInfo | null> {
|
|
369
|
+
return await this.requireExactIssuer().status(request);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Administrative recovery; embedding services must authorize the caller. */
|
|
373
|
+
public async recoverCertificateIssuance(request: IExactIssuanceRecoveryRequest): Promise<TExactCertificateResult> {
|
|
374
|
+
return await this.runExact(request, request);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private requireExactIssuer(): ExactCertificateIssuer {
|
|
378
|
+
if (!this.started || !this.exactIssuer) throw new Error('SmartAcme explicit issuance is not running or configured');
|
|
379
|
+
return this.exactIssuer;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private async runExact(request: IExactCertificateRequest, recovery?: IExactIssuanceRecoveryRequest): Promise<TExactCertificateResult> {
|
|
383
|
+
const issuer = this.requireExactIssuer();
|
|
384
|
+
const identity = issuer.identity(request);
|
|
385
|
+
const recoverySnapshot = recovery ? {
|
|
386
|
+
namespace: identity.namespace, identifiers: [...identity.identifiers],
|
|
387
|
+
issuanceId: recovery.issuanceId, expectedRevision: recovery.expectedRevision, action: recovery.action,
|
|
388
|
+
} : undefined;
|
|
389
|
+
const existing = this.exactRequests.get(identity.certificateKey);
|
|
390
|
+
if (existing) {
|
|
391
|
+
if (recovery) throw new Error('Issuance is running; recover after its current operation completes');
|
|
392
|
+
return await existing;
|
|
393
|
+
}
|
|
394
|
+
if (this.exactRequests.size >= (this.options.maxPendingExactRequests ?? 64)) {
|
|
395
|
+
throw new Error('Explicit issuance admission limit reached');
|
|
396
|
+
}
|
|
397
|
+
const operation = (async (): Promise<TExactCertificateResult> => {
|
|
398
|
+
if (!recovery) {
|
|
399
|
+
const cached = await issuer.cached(identity);
|
|
400
|
+
if (cached) return cached;
|
|
401
|
+
}
|
|
402
|
+
this.lifecycleAbort.signal.throwIfAborted();
|
|
403
|
+
const result = await this.taskManager.triggerTaskConstrained(this.certIssuanceTask, {
|
|
404
|
+
certDomainName: identity.certificateKey, domainArg: identity.identifiers[0],
|
|
405
|
+
isWildcardRequest: false, includeWildcard: false, forceRenew: false,
|
|
406
|
+
exact: { request: { namespace: identity.namespace, identifiers: identity.identifiers }, recovery: recoverySnapshot },
|
|
407
|
+
} satisfies ICertIssuanceInput) as TExactCertificateResult | undefined;
|
|
408
|
+
if (!result) throw new Error('Explicit issuance completed without a result');
|
|
409
|
+
return result;
|
|
410
|
+
})();
|
|
411
|
+
this.exactRequests.set(identity.certificateKey, operation);
|
|
412
|
+
try { return await operation; }
|
|
413
|
+
finally { this.exactRequests.delete(identity.certificateKey); }
|
|
414
|
+
}
|
|
284
415
|
/** Retry helper with exponential backoff and AcmeError awareness */
|
|
285
416
|
private async retry<T>(operation: () => Promise<T>, operationName: string = 'operation'): Promise<T> {
|
|
286
417
|
let attempt = 0;
|
|
@@ -315,12 +446,12 @@ export class SmartAcme {
|
|
|
315
446
|
}
|
|
316
447
|
/** Clean up pending challenges and shut down */
|
|
317
448
|
private async handleShutdown(): Promise<void> {
|
|
318
|
-
for (const input of [...this.pendingChallenges]) {
|
|
449
|
+
for (const { input, context } of [...this.pendingChallenges]) {
|
|
319
450
|
const type: string = (input as any).type;
|
|
320
451
|
const handler = this.challengeHandlers.find((h) => h.getSupportedTypes().includes(type));
|
|
321
452
|
if (handler) {
|
|
322
453
|
try {
|
|
323
|
-
await handler.cleanup(input);
|
|
454
|
+
await handler.cleanup(input, { ...context });
|
|
324
455
|
await this.logger.log('info', `Removed pending ${type} challenge during shutdown`, input);
|
|
325
456
|
} catch (err) {
|
|
326
457
|
await this.logger.log('error', `Failed to remove pending ${type} challenge during shutdown`, err);
|
|
@@ -457,6 +588,11 @@ export class SmartAcme {
|
|
|
457
588
|
identifiers,
|
|
458
589
|
}), 'createOrder');
|
|
459
590
|
|
|
591
|
+
const challengeContext: plugins.handlers.IChallengeContext = {
|
|
592
|
+
operationId: plugins.crypto.createHash('sha256')
|
|
593
|
+
.update(JSON.stringify(['smartacme-legacy-challenge-v1', order.url])).digest('hex'),
|
|
594
|
+
};
|
|
595
|
+
|
|
460
596
|
// ── Step: authorize ───────────────────────────────────────────────────
|
|
461
597
|
this.certIssuanceTask.notifyStep('authorize');
|
|
462
598
|
|
|
@@ -498,9 +634,9 @@ export class SmartAcme {
|
|
|
498
634
|
} else {
|
|
499
635
|
challengeInput = { type, keyAuthorization: keyAuth, ...selectedChallengeArg };
|
|
500
636
|
}
|
|
501
|
-
this.pendingChallenges.push(challengeInput);
|
|
637
|
+
this.pendingChallenges.push({ input: challengeInput, context: challengeContext });
|
|
502
638
|
try {
|
|
503
|
-
await this.retry(() => handler.prepare(challengeInput), `${type}.prepare`);
|
|
639
|
+
await this.retry(() => handler.prepare(challengeInput, { ...challengeContext }), `${type}.prepare`);
|
|
504
640
|
if (type === 'dns-01') {
|
|
505
641
|
const dnsInput = challengeInput as { hostName: string; challenge: string };
|
|
506
642
|
await this.retry(
|
|
@@ -528,11 +664,11 @@ export class SmartAcme {
|
|
|
528
664
|
}
|
|
529
665
|
} finally {
|
|
530
666
|
try {
|
|
531
|
-
await this.retry(() => handler.cleanup(challengeInput), `${type}.cleanup`);
|
|
667
|
+
await this.retry(() => handler.cleanup(challengeInput, { ...challengeContext }), `${type}.cleanup`);
|
|
532
668
|
} catch (err) {
|
|
533
669
|
await this.logger.log('error', `Error during ${type}.cleanup`, err);
|
|
534
670
|
} finally {
|
|
535
|
-
this.pendingChallenges = this.pendingChallenges.filter((c) => c !== challengeInput);
|
|
671
|
+
this.pendingChallenges = this.pendingChallenges.filter((c) => c.input !== challengeInput);
|
|
536
672
|
}
|
|
537
673
|
}
|
|
538
674
|
}
|
package/readme.hints.md
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
- this repo is dependent on letsencrypt and its limits
|
|
2
|
-
- to simpify the outside API, smartacme is stateful, meaning it works with a mongodb and a collection called 'SmartacmeCert'.
|
|
3
|
-
|
|
4
|
-
## Certificate Request Behavior
|
|
5
|
-
|
|
6
|
-
As of v7.4.0, SmartAcme no longer automatically requests wildcard certificates for all domain requests. This change was made to fix issues with HTTP-01 only configurations which cannot validate wildcard domains.
|
|
7
|
-
|
|
8
|
-
- By default, `getCertificateForDomain('example.com')` only requests a certificate for `example.com`
|
|
9
|
-
- To request both regular and wildcard certificates, use `getCertificateForDomain('example.com', { includeWildcard: true })`
|
|
10
|
-
- Wildcard certificates require a DNS-01 challenge handler to be configured
|
|
11
|
-
- Direct wildcard requests like `getCertificateForDomain('*.example.com')` only request the wildcard certificate
|
|
12
|
-
|
|
13
|
-
This change ensures HTTP-01 only configurations work properly while still allowing wildcard certificates when needed and supported.
|
|
14
|
-
|
|
15
|
-
## ACME Protocol Implementation
|
|
16
|
-
|
|
17
|
-
As of v8.1.0, the `acme-client` npm package has been replaced with a custom OOP implementation under `ts/acme/`. This uses `node:crypto` for all cryptographic operations and `@peculiar/x509` solely for CSR generation. The implementation follows RFC 8555.
|
|
18
|
-
|
|
19
|
-
Key files:
|
|
20
|
-
- `ts/acme/acme.classes.client.ts` — Top-level facade (`AcmeClient`), accepts optional `logger` callback
|
|
21
|
-
- `ts/acme/acme.classes.crypto.ts` — Key gen, JWK, JWS signing, CSR (`AcmeCrypto`)
|
|
22
|
-
- `ts/acme/acme.classes.http-client.ts` — JWS-signed HTTP transport with nonce management and logging
|
|
23
|
-
- `ts/acme/acme.classes.error.ts` — Structured `AcmeError` with type URN, subproblems, Retry-After, `isRetryable`/`isRateLimited`
|
|
24
|
-
- `ts/acme/acme.classes.account.ts` — Account registration
|
|
25
|
-
- `ts/acme/acme.classes.order.ts` — Order lifecycle + polling
|
|
26
|
-
- `ts/acme/acme.classes.challenge.ts` — Key authorization + challenge completion
|
|
27
|
-
- `ts/acme/acme.classes.directory.ts` — CA directory URL constants (`ACME_DIRECTORY_URLS`)
|
|
28
|
-
|
|
29
|
-
Usage in `ts/plugins.ts`: `import * as acme from './acme/index.js'` (replaces `acme-client`)
|
|
30
|
-
|
|
31
|
-
## Concurrency & Rate Limiting (taskbuffer integration)
|
|
32
|
-
|
|
33
|
-
As of v9.1.0, `@push.rocks/lik.InterestMap` was replaced with `@push.rocks/taskbuffer.TaskManager` for coordinating concurrent certificate requests. This provides:
|
|
34
|
-
|
|
35
|
-
- **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.
|
|
36
|
-
- **Global concurrency cap** (`acme-global-concurrency`): Limits total parallel ACME operations (default 5, configurable via `maxConcurrentIssuances`).
|
|
37
|
-
- **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.
|
|
38
|
-
- **Step-based progress**: The cert issuance task uses `notifyStep()` for prepare/authorize/finalize/store phases, observable via `smartAcme.certIssuanceEvents`.
|
|
39
|
-
|
|
40
|
-
Key implementation details:
|
|
41
|
-
- A single reusable `Task` named `cert-issuance` handles all domains via `triggerTaskConstrained()` with different inputs.
|
|
42
|
-
- The `shouldExecute` callback on the domain mutex checks the certmanager cache as a safety net.
|
|
43
|
-
- `TaskManager.start()` is called in `SmartAcme.start()` and `TaskManager.stop()` in `SmartAcme.stop()`.
|
|
44
|
-
- The "no cronjobs specified" log messages during tests come from taskbuffer's internal CronManager polling — harmless noise when no cron tasks are scheduled.
|
|
45
|
-
|
|
46
|
-
## ACME Directory Server (ts_server/)
|
|
47
|
-
|
|
48
|
-
As of v9.2.0, a built-in ACME Directory Server lives under `ts_server/`. This is a full RFC 8555-compliant CA server that allows running your own Certificate Authority.
|
|
49
|
-
|
|
50
|
-
Key files:
|
|
51
|
-
- `ts_server/server.classes.acmeserver.ts` — Top-level `AcmeServer` facade (start/stop/config)
|
|
52
|
-
- `ts_server/server.classes.ca.ts` — Self-signed root CA generation + certificate signing via `@peculiar/x509`
|
|
53
|
-
- `ts_server/server.classes.jws.verifier.ts` — JWS signature verification (inverse of `AcmeCrypto.createJws`)
|
|
54
|
-
- `ts_server/server.classes.router.ts` — Minimal HTTP router with `:param` support using raw `node:http`
|
|
55
|
-
- `ts_server/server.classes.nonce.ts` — Single-use replay nonce management
|
|
56
|
-
- `ts_server/server.classes.challenge.verifier.ts` — HTTP-01/DNS-01 verification (with bypass mode)
|
|
57
|
-
- `ts_server/server.classes.account.store.ts` — In-memory account storage
|
|
58
|
-
- `ts_server/server.classes.order.store.ts` — In-memory order/authz/challenge/cert storage
|
|
59
|
-
- `ts_server/server.handlers.*.ts` — Route handlers for each ACME endpoint
|
|
60
|
-
|
|
61
|
-
Design decisions:
|
|
62
|
-
- Uses raw `node:http` (no framework dependency — `@api.global/typedserver` was explicitly removed in v8.1.0)
|
|
63
|
-
- Zero new dependencies: uses `node:crypto`, `@peculiar/x509`, and existing project deps
|
|
64
|
-
- Reuses `AcmeCrypto` for JWK thumbprint/base64url, ACME interfaces for response types, `AcmeError` patterns
|
|
65
|
-
- `AcmeCrypto.getAlg()` was made public (was private) for use by the JWS verifier
|
|
66
|
-
- Storage interfaces (`IServerAccountStore`, `IServerOrderStore`) are pluggable, with in-memory defaults
|
|
67
|
-
- `challengeVerification: false` option auto-approves challenges for testing
|
|
68
|
-
- `tsbuild tsfolders` automatically compiles `ts_server/` to `dist_ts_server/`
|
|
69
|
-
|
|
70
|
-
## Dependency Notes
|
|
71
|
-
|
|
72
|
-
- `acme-client` was replaced with custom implementation in `ts/acme/` + `@peculiar/x509` for CSR generation
|
|
73
|
-
- `@push.rocks/smartfile`, `@api.global/typedserver`, `@push.rocks/smartrequest`, `@push.rocks/smartpromise` were removed as unused dependencies in v8.1.0
|
|
74
|
-
- The `@apiclient.xyz/cloudflare` `convenience` namespace is deprecated but still functional. The `Dns01Handler` accepts an `IConvenientDnsProvider` interface which remains stable.
|
|
75
|
-
- Test imports use `@git.zone/tstest/tapbundle` (not `@push.rocks/tapbundle`)
|
|
76
|
-
- Build uses `tsbuild tsfolders` (v4.4.0+) — auto-discovers and compiles `ts/` and `ts_server/` directories
|
|
77
|
-
- `@peculiar/x509` v2.0.0 removed `reflect-metadata` from its dependencies. Since `tsyringe` (used internally by `@peculiar/x509`) requires the Reflect polyfill, `reflect-metadata` is now a direct dependency and imported in `ts/acme/acme.classes.crypto.ts` and `ts_server/server.classes.ca.ts`.
|
|
78
|
-
- `@push.rocks/taskbuffer` upgraded from v6 to v8 (required by smartdata 7.1.3). API surface is backward-compatible.
|
|
79
|
-
- TypeScript 6 (via tsbuild 4.4.0) requires `"types": ["node"]` in tsconfig.json for `ts_server/` compilation to resolve `@types/node`.
|
|
80
|
-
- TypeScript 6 deprecated `baseUrl` in tsconfig — removed it since `paths` was empty.
|
|
81
|
-
- Config file renamed from `npmextra.json` to `.smartconfig.json` (ecosystem convention change).
|
package/readme.plan.md
DELETED