@push.rocks/smartacme 4.0.8 → 6.0.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.
Files changed (38) hide show
  1. package/LICENSE +1 -1
  2. package/dist_ts/00_commitinfo_data.d.ts +1 -1
  3. package/dist_ts/00_commitinfo_data.js +4 -4
  4. package/dist_ts/handlers/Dns01Handler.d.ts +13 -0
  5. package/dist_ts/handlers/Dns01Handler.js +24 -0
  6. package/dist_ts/handlers/Http01Handler.d.ts +34 -0
  7. package/dist_ts/handlers/Http01Handler.js +32 -0
  8. package/dist_ts/handlers/IChallengeHandler.d.ts +22 -0
  9. package/dist_ts/handlers/IChallengeHandler.js +2 -0
  10. package/dist_ts/handlers/index.d.ts +3 -0
  11. package/dist_ts/handlers/index.js +4 -0
  12. package/dist_ts/index.d.ts +1 -1
  13. package/dist_ts/index.js +2 -2
  14. package/dist_ts/smartacme.classes.cert.d.ts +1 -1
  15. package/dist_ts/smartacme.classes.cert.js +14 -14
  16. package/dist_ts/smartacme.classes.certmanager.d.ts +4 -4
  17. package/dist_ts/smartacme.classes.certmanager.js +7 -7
  18. package/dist_ts/smartacme.classes.certmatcher.d.ts +1 -1
  19. package/dist_ts/smartacme.classes.certmatcher.js +2 -2
  20. package/dist_ts/smartacme.classes.smartacme.d.ts +39 -10
  21. package/dist_ts/smartacme.classes.smartacme.js +150 -48
  22. package/dist_ts/smartacme.plugins.d.ts +4 -2
  23. package/dist_ts/smartacme.plugins.js +6 -3
  24. package/npmextra.json +23 -3
  25. package/package.json +39 -27
  26. package/readme.hints.md +2 -0
  27. package/readme.md +235 -39
  28. package/ts/00_commitinfo_data.ts +3 -3
  29. package/ts/handlers/Dns01Handler.ts +40 -0
  30. package/ts/handlers/Http01Handler.ts +54 -0
  31. package/ts/handlers/IChallengeHandler.ts +22 -0
  32. package/ts/handlers/index.ts +4 -0
  33. package/ts/index.ts +1 -1
  34. package/ts/smartacme.classes.cert.ts +4 -4
  35. package/ts/smartacme.classes.certmanager.ts +9 -9
  36. package/ts/smartacme.classes.certmatcher.ts +1 -1
  37. package/ts/smartacme.classes.smartacme.ts +185 -64
  38. package/ts/smartacme.plugins.ts +5 -2
@@ -1,7 +1,8 @@
1
1
  import * as plugins from './smartacme.plugins.js';
2
- import { Cert } from './smartacme.classes.cert.js';
3
- import { CertManager } from './smartacme.classes.certmanager.js';
4
- import { CertMatcher } from './smartacme.classes.certmatcher.js';
2
+ import { SmartacmeCert } from './smartacme.classes.cert.js';
3
+ import { SmartacmeCertManager } from './smartacme.classes.certmanager.js';
4
+ import { SmartacmeCertMatcher } from './smartacme.classes.certmatcher.js';
5
+ import { commitinfo } from './00_commitinfo_data.js';
5
6
 
6
7
  /**
7
8
  * the options for the class @see SmartAcme
@@ -10,9 +11,30 @@ export interface ISmartAcmeOptions {
10
11
  accountPrivateKey?: string;
11
12
  accountEmail: string;
12
13
  mongoDescriptor: plugins.smartdata.IMongoDescriptor;
13
- setChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise<any>;
14
- removeChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise<any>;
14
+ // Removed legacy setChallenge/removeChallenge in favor of `challengeHandlers`
15
15
  environment: 'production' | 'integration';
16
+ /**
17
+ * Optional retry/backoff configuration for transient failures
18
+ */
19
+ retryOptions?: {
20
+ /** number of retry attempts */
21
+ retries?: number;
22
+ /** backoff multiplier */
23
+ factor?: number;
24
+ /** initial delay in milliseconds */
25
+ minTimeoutMs?: number;
26
+ /** maximum delay cap in milliseconds */
27
+ maxTimeoutMs?: number;
28
+ };
29
+ /**
30
+ * Pluggable ACME challenge handlers (DNS-01, HTTP-01, TLS-ALPN-01, etc.)
31
+ */
32
+ challengeHandlers?: plugins.handlers.IChallengeHandler<any>[];
33
+ /**
34
+ * Order of challenge types to try (e.g. ['http-01','dns-01']).
35
+ * Defaults to ['dns-01'] or first supported type from handlers.
36
+ */
37
+ challengePriority?: string[];
16
38
  }
17
39
 
18
40
  /**
@@ -29,46 +51,70 @@ export class SmartAcme {
29
51
  private options: ISmartAcmeOptions;
30
52
 
31
53
  // the acme client
32
- private client: any;
33
- private smartdns = new plugins.smartdns.Smartdns({});
34
- public logger: plugins.smartlog.ConsoleLog;
54
+ private client: plugins.acme.Client;
55
+ private smartdns = new plugins.smartdnsClient.Smartdns({});
56
+ public logger: plugins.smartlog.Smartlog;
35
57
 
36
58
  // the account private key
37
59
  private privateKey: string;
38
60
 
39
- // challenge fullfillment
40
- private setChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise<any>;
41
- private removeChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise<any>;
42
61
 
43
62
  // certmanager
44
- private certmanager: CertManager;
45
- private certmatcher: CertMatcher;
63
+ private certmanager: SmartacmeCertManager;
64
+ private certmatcher: SmartacmeCertMatcher;
65
+ // retry/backoff configuration (resolved with defaults)
66
+ private retryOptions: { retries: number; factor: number; minTimeoutMs: number; maxTimeoutMs: number };
67
+ // track pending DNS challenges for graceful shutdown
68
+ private pendingChallenges: plugins.tsclass.network.IDnsChallenge[] = [];
69
+ // configured pluggable ACME challenge handlers
70
+ private challengeHandlers: plugins.handlers.IChallengeHandler<any>[];
71
+ // priority order of challenge types
72
+ private challengePriority: string[];
46
73
 
47
74
  constructor(optionsArg: ISmartAcmeOptions) {
48
75
  this.options = optionsArg;
49
- this.logger = new plugins.smartlog.ConsoleLog();
76
+ this.logger = plugins.smartlog.Smartlog.createForCommitinfo(commitinfo);
77
+ // enable console output for structured logging
78
+ this.logger.enableConsole();
79
+ // initialize retry/backoff options
80
+ this.retryOptions = {
81
+ retries: optionsArg.retryOptions?.retries ?? 3,
82
+ factor: optionsArg.retryOptions?.factor ?? 2,
83
+ minTimeoutMs: optionsArg.retryOptions?.minTimeoutMs ?? 1000,
84
+ maxTimeoutMs: optionsArg.retryOptions?.maxTimeoutMs ?? 30000,
85
+ };
86
+ // initialize challenge handlers (must provide at least one)
87
+ if (!optionsArg.challengeHandlers || optionsArg.challengeHandlers.length === 0) {
88
+ throw new Error(
89
+ 'You must provide at least one ACME challenge handler via options.challengeHandlers',
90
+ );
91
+ }
92
+ this.challengeHandlers = optionsArg.challengeHandlers;
93
+ // initialize challenge priority
94
+ this.challengePriority =
95
+ optionsArg.challengePriority && optionsArg.challengePriority.length > 0
96
+ ? optionsArg.challengePriority
97
+ : this.challengeHandlers.map((h) => h.getSupportedTypes()[0]);
50
98
  }
51
99
 
52
100
  /**
53
- * inits the instance
101
+ * starts the instance
54
102
  * ```ts
55
- * await myCloudlyInstance.init() // does not support options
103
+ * await myCloudlyInstance.start() // does not support options
56
104
  * ```
57
105
  */
58
- public async init() {
106
+ public async start() {
59
107
  this.privateKey =
60
108
  this.options.accountPrivateKey || (await plugins.acme.forge.createPrivateKey()).toString();
61
- this.setChallenge = this.options.setChallenge;
62
- this.removeChallenge = this.options.removeChallenge;
63
109
 
64
110
  // CertMangaer
65
- this.certmanager = new CertManager(this, {
111
+ this.certmanager = new SmartacmeCertManager(this, {
66
112
  mongoDescriptor: this.options.mongoDescriptor,
67
113
  });
68
114
  await this.certmanager.init();
69
115
 
70
116
  // CertMatcher
71
- this.certmatcher = new CertMatcher();
117
+ this.certmatcher = new SmartacmeCertMatcher();
72
118
 
73
119
  // ACME Client
74
120
  this.client = new plugins.acme.Client({
@@ -87,11 +133,65 @@ export class SmartAcme {
87
133
  termsOfServiceAgreed: true,
88
134
  contact: [`mailto:${this.options.accountEmail}`],
89
135
  });
136
+ // Setup graceful shutdown handlers
137
+ process.on('SIGINT', () => this.handleSignal('SIGINT'));
138
+ process.on('SIGTERM', () => this.handleSignal('SIGTERM'));
90
139
  }
91
140
 
92
- public async stop() {
93
- await this.certmanager.smartdataDb.close();
94
- }
141
+ public async stop() {
142
+ await this.certmanager.smartdataDb.close();
143
+ }
144
+ /** Retry helper with exponential backoff */
145
+ private async retry<T>(operation: () => Promise<T>, operationName: string = 'operation'): Promise<T> {
146
+ let attempt = 0;
147
+ let delay = this.retryOptions.minTimeoutMs;
148
+ while (true) {
149
+ try {
150
+ return await operation();
151
+ } catch (err) {
152
+ attempt++;
153
+ if (attempt > this.retryOptions.retries) {
154
+ await this.logger.log('error', `Operation ${operationName} failed after ${attempt} attempts`, err);
155
+ throw err;
156
+ }
157
+ await this.logger.log('warn', `Operation ${operationName} failed on attempt ${attempt}, retrying in ${delay}ms`, err);
158
+ await plugins.smartdelay.delayFor(delay);
159
+ delay = Math.min(delay * this.retryOptions.factor, this.retryOptions.maxTimeoutMs);
160
+ }
161
+ }
162
+ }
163
+ /** Clean up pending challenges and shut down */
164
+ private async handleShutdown(): Promise<void> {
165
+ for (const input of [...this.pendingChallenges]) {
166
+ const type: string = (input as any).type;
167
+ const handler = this.challengeHandlers.find((h) => h.getSupportedTypes().includes(type));
168
+ if (handler) {
169
+ try {
170
+ await handler.cleanup(input);
171
+ await this.logger.log('info', `Removed pending ${type} challenge during shutdown`, input);
172
+ } catch (err) {
173
+ await this.logger.log('error', `Failed to remove pending ${type} challenge during shutdown`, err);
174
+ }
175
+ } else {
176
+ await this.logger.log(
177
+ 'warn',
178
+ `No handler for pending challenge type '${type}' during shutdown; skipping cleanup`,
179
+ input,
180
+ );
181
+ }
182
+ }
183
+ this.pendingChallenges = [];
184
+ await this.stop();
185
+ }
186
+ /** Handle process signals for graceful shutdown */
187
+ private handleSignal(sig: string): void {
188
+ this.logger.log('info', `Received signal ${sig}, shutting down gracefully`);
189
+ this.handleShutdown()
190
+ .then(() => process.exit(0))
191
+ .catch((err) => {
192
+ this.logger.log('error', 'Error during shutdown', err).then(() => process.exit(1));
193
+ });
194
+ }
95
195
 
96
196
  /**
97
197
  * gets a certificate
@@ -107,7 +207,7 @@ export class SmartAcme {
107
207
  *
108
208
  * @param domainArg
109
209
  */
110
- public async getCertificateForDomain(domainArg: string): Promise<Cert> {
210
+ public async getCertificateForDomain(domainArg: string): Promise<SmartacmeCert> {
111
211
  const certDomainName = this.certmatcher.getCertificateDomainNameByDomainName(domainArg);
112
212
  const retrievedCertificate = await this.certmanager.retrieveCertificate(certDomainName);
113
213
 
@@ -127,54 +227,71 @@ export class SmartAcme {
127
227
  // lets make sure others get the same interest
128
228
  const currentDomainInterst = await this.certmanager.interestMap.addInterest(certDomainName);
129
229
 
130
- /* Place new order */
131
- const order = await this.client.createOrder({
230
+ /* Place new order with retry */
231
+ const order = await this.retry(() => this.client.createOrder({
132
232
  identifiers: [
133
233
  { type: 'dns', value: certDomainName },
134
234
  { type: 'dns', value: `*.${certDomainName}` },
135
235
  ],
136
- });
236
+ }), 'createOrder');
137
237
 
138
238
  /* Get authorizations and select challenges */
139
- const authorizations = await this.client.getAuthorizations(order);
239
+ const authorizations = await this.retry(() => this.client.getAuthorizations(order), 'getAuthorizations');
140
240
 
141
241
  for (const authz of authorizations) {
142
- console.log(authz);
143
- const fullHostName: string = `_acme-challenge.${authz.identifier.value}`;
144
- const dnsChallenge: string = authz.challenges.find((challengeArg) => {
145
- return challengeArg.type === 'dns-01';
146
- });
147
- // process.exit(1);
148
- const keyAuthorization: string = await this.client.getChallengeKeyAuthorization(dnsChallenge);
149
-
242
+ await this.logger.log('debug', 'Authorization received', authz);
243
+ // select a handler based on configured priority
244
+ let selectedHandler: { type: string; handler: plugins.handlers.IChallengeHandler<any> } | null = null;
245
+ let selectedChallengeArg: any = null;
246
+ for (const type of this.challengePriority) {
247
+ const candidate = authz.challenges.find((c: any) => c.type === type);
248
+ if (!candidate) continue;
249
+ const handler = this.challengeHandlers.find((h) => h.getSupportedTypes().includes(type));
250
+ if (handler) {
251
+ selectedHandler = { type, handler };
252
+ selectedChallengeArg = candidate;
253
+ break;
254
+ }
255
+ }
256
+ if (!selectedHandler) {
257
+ throw new Error(`No challenge handler for domain ${authz.identifier.value}: supported types [${this.challengePriority.join(',')}]`);
258
+ }
259
+ const { type, handler } = selectedHandler;
260
+ // build handler input with keyAuthorization
261
+ let input: any;
262
+ // retrieve keyAuthorization for challenge
263
+ const keyAuth = await this.client.getChallengeKeyAuthorization(selectedChallengeArg);
264
+ if (type === 'dns-01') {
265
+ input = { type, hostName: `_acme-challenge.${authz.identifier.value}`, challenge: keyAuth };
266
+ } else if (type === 'http-01') {
267
+ // HTTP-01 requires serving token at webPath
268
+ input = {
269
+ type,
270
+ token: (selectedChallengeArg as any).token,
271
+ keyAuthorization: keyAuth,
272
+ webPath: `/.well-known/acme-challenge/${(selectedChallengeArg as any).token}`,
273
+ };
274
+ } else {
275
+ // generic challenge input: include raw challenge properties
276
+ input = { type, keyAuthorization: keyAuth, ...selectedChallengeArg };
277
+ }
278
+ this.pendingChallenges.push(input);
150
279
  try {
151
- /* Satisfy challenge */
152
- await this.setChallenge({
153
- hostName: fullHostName,
154
- challenge: keyAuthorization,
155
- });
156
- await plugins.smartdelay.delayFor(30000);
157
- await this.smartdns.checkUntilAvailable(fullHostName, 'TXT', keyAuthorization, 100, 5000);
158
- console.log('Cool down an extra 60 second for region availability');
159
- await plugins.smartdelay.delayFor(60000);
160
-
161
- /* Verify that challenge is satisfied */
162
- await this.client.verifyChallenge(authz, dnsChallenge);
163
-
164
- /* Notify ACME provider that challenge is satisfied */
165
- await this.client.completeChallenge(dnsChallenge);
166
-
167
- /* Wait for ACME provider to respond with valid status */
168
- await this.client.waitForValidStatus(dnsChallenge);
280
+ await this.retry(() => handler.prepare(input), `${type}.prepare`);
281
+ if (handler.verify) {
282
+ await this.retry(() => handler.verify!(input), `${type}.verify`);
283
+ } else {
284
+ await this.retry(() => this.client.verifyChallenge(authz, selectedChallengeArg), `${type}.verifyChallenge`);
285
+ }
286
+ await this.retry(() => this.client.completeChallenge(selectedChallengeArg), `${type}.completeChallenge`);
287
+ await this.retry(() => this.client.waitForValidStatus(selectedChallengeArg), `${type}.waitForValidStatus`);
169
288
  } finally {
170
- /* Clean up challenge response */
171
289
  try {
172
- await this.removeChallenge({
173
- hostName: fullHostName,
174
- challenge: keyAuthorization,
175
- });
176
- } catch (e) {
177
- console.log(e);
290
+ await this.retry(() => handler.cleanup(input), `${type}.cleanup`);
291
+ } catch (err) {
292
+ await this.logger.log('error', `Error during ${type}.cleanup`, err);
293
+ } finally {
294
+ this.pendingChallenges = this.pendingChallenges.filter((c) => c !== input);
178
295
  }
179
296
  }
180
297
  }
@@ -185,8 +302,8 @@ export class SmartAcme {
185
302
  altNames: [certDomainName],
186
303
  });
187
304
 
188
- await this.client.finalizeOrder(order, csr);
189
- const cert = await this.client.getCertificate(order);
305
+ await this.retry(() => this.client.finalizeOrder(order, csr), 'finalizeOrder');
306
+ const cert = await this.retry(() => this.client.getCertificate(order), 'getCertificate');
190
307
 
191
308
  /* Done */
192
309
 
@@ -209,4 +326,8 @@ export class SmartAcme {
209
326
  currentDomainInterst.destroy();
210
327
  return newCertificate;
211
328
  }
329
+
330
+ public async getAllCertificates(): Promise<SmartacmeCert[]> {
331
+ return SmartacmeCert.getInstances({});
332
+ }
212
333
  }
@@ -7,7 +7,7 @@ export { typedserver };
7
7
  import * as lik from '@push.rocks/lik';
8
8
  import * as smartdata from '@push.rocks/smartdata';
9
9
  import * as smartdelay from '@push.rocks/smartdelay';
10
- import * as smartdns from '@push.rocks/smartdns';
10
+ import * as smartdnsClient from '@push.rocks/smartdns/client';
11
11
  import * as smartlog from '@push.rocks/smartlog';
12
12
  import * as smartpromise from '@push.rocks/smartpromise';
13
13
  import * as smartrequest from '@push.rocks/smartrequest';
@@ -19,7 +19,7 @@ export {
19
19
  lik,
20
20
  smartdata,
21
21
  smartdelay,
22
- smartdns,
22
+ smartdnsClient,
23
23
  smartlog,
24
24
  smartpromise,
25
25
  smartrequest,
@@ -37,3 +37,6 @@ export { tsclass };
37
37
  import * as acme from 'acme-client';
38
38
 
39
39
  export { acme };
40
+ // local handlers for challenge types
41
+ import * as handlers from './handlers/index.js';
42
+ export { handlers };