@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.
- package/LICENSE +1 -1
- package/dist_ts/00_commitinfo_data.d.ts +1 -1
- package/dist_ts/00_commitinfo_data.js +4 -4
- package/dist_ts/handlers/Dns01Handler.d.ts +13 -0
- package/dist_ts/handlers/Dns01Handler.js +24 -0
- package/dist_ts/handlers/Http01Handler.d.ts +34 -0
- package/dist_ts/handlers/Http01Handler.js +32 -0
- package/dist_ts/handlers/IChallengeHandler.d.ts +22 -0
- package/dist_ts/handlers/IChallengeHandler.js +2 -0
- package/dist_ts/handlers/index.d.ts +3 -0
- package/dist_ts/handlers/index.js +4 -0
- package/dist_ts/index.d.ts +1 -1
- package/dist_ts/index.js +2 -2
- package/dist_ts/smartacme.classes.cert.d.ts +1 -1
- package/dist_ts/smartacme.classes.cert.js +14 -14
- package/dist_ts/smartacme.classes.certmanager.d.ts +4 -4
- package/dist_ts/smartacme.classes.certmanager.js +7 -7
- package/dist_ts/smartacme.classes.certmatcher.d.ts +1 -1
- package/dist_ts/smartacme.classes.certmatcher.js +2 -2
- package/dist_ts/smartacme.classes.smartacme.d.ts +39 -10
- package/dist_ts/smartacme.classes.smartacme.js +150 -48
- package/dist_ts/smartacme.plugins.d.ts +4 -2
- package/dist_ts/smartacme.plugins.js +6 -3
- package/npmextra.json +23 -3
- package/package.json +39 -27
- package/readme.hints.md +2 -0
- package/readme.md +235 -39
- package/ts/00_commitinfo_data.ts +3 -3
- package/ts/handlers/Dns01Handler.ts +40 -0
- package/ts/handlers/Http01Handler.ts +54 -0
- package/ts/handlers/IChallengeHandler.ts +22 -0
- package/ts/handlers/index.ts +4 -0
- package/ts/index.ts +1 -1
- package/ts/smartacme.classes.cert.ts +4 -4
- package/ts/smartacme.classes.certmanager.ts +9 -9
- package/ts/smartacme.classes.certmatcher.ts +1 -1
- package/ts/smartacme.classes.smartacme.ts +185 -64
- package/ts/smartacme.plugins.ts +5 -2
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as plugins from './smartacme.plugins.js';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
|
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:
|
|
33
|
-
private smartdns = new plugins.
|
|
34
|
-
public logger: plugins.smartlog.
|
|
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:
|
|
45
|
-
private 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 =
|
|
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
|
-
*
|
|
101
|
+
* starts the instance
|
|
54
102
|
* ```ts
|
|
55
|
-
* await myCloudlyInstance.
|
|
103
|
+
* await myCloudlyInstance.start() // does not support options
|
|
56
104
|
* ```
|
|
57
105
|
*/
|
|
58
|
-
public async
|
|
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
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
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<
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
await this.
|
|
158
|
-
|
|
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.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
}
|
package/ts/smartacme.plugins.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 };
|