@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
package/readme.hints.md
CHANGED
|
@@ -28,6 +28,21 @@ Key files:
|
|
|
28
28
|
|
|
29
29
|
Usage in `ts/plugins.ts`: `import * as acme from './acme/index.js'` (replaces `acme-client`)
|
|
30
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
|
+
|
|
31
46
|
## Dependency Notes
|
|
32
47
|
|
|
33
48
|
- `acme-client` was replaced with custom implementation in `ts/acme/` + `@peculiar/x509` for CSR generation
|
package/readme.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @push.rocks/smartacme
|
|
2
2
|
|
|
3
|
-
A TypeScript-based ACME client for
|
|
3
|
+
A TypeScript-based ACME client for Let's Encrypt certificate management with a focus on simplicity and power. ๐
|
|
4
4
|
|
|
5
5
|
## Issue Reporting and Security
|
|
6
6
|
|
|
@@ -16,9 +16,9 @@ Ensure your project uses TypeScript and ECMAScript Modules (ESM).
|
|
|
16
16
|
|
|
17
17
|
## Usage
|
|
18
18
|
|
|
19
|
-
`@push.rocks/smartacme` automates the full ACME certificate lifecycle โ obtaining, renewing, and storing SSL/TLS certificates from Let's Encrypt. It
|
|
19
|
+
`@push.rocks/smartacme` automates the full ACME certificate lifecycle โ obtaining, renewing, and storing SSL/TLS certificates from Let's Encrypt. It features a built-in RFC 8555-compliant ACME protocol implementation, pluggable challenge handlers (DNS-01, HTTP-01), pluggable certificate storage backends (MongoDB, in-memory, or your own), and structured error handling with smart retry logic.
|
|
20
20
|
|
|
21
|
-
### Quick Start
|
|
21
|
+
### ๐ Quick Start
|
|
22
22
|
|
|
23
23
|
```typescript
|
|
24
24
|
import { SmartAcme, certmanagers, handlers } from '@push.rocks/smartacme';
|
|
@@ -47,39 +47,40 @@ await smartAcme.start();
|
|
|
47
47
|
|
|
48
48
|
// 4. Get a certificate
|
|
49
49
|
const cert = await smartAcme.getCertificateForDomain('example.com');
|
|
50
|
-
console.log(cert.publicKey); // PEM certificate
|
|
50
|
+
console.log(cert.publicKey); // PEM certificate chain
|
|
51
51
|
console.log(cert.privateKey); // PEM private key
|
|
52
52
|
|
|
53
53
|
// 5. Clean up
|
|
54
54
|
await smartAcme.stop();
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
### SmartAcme Options
|
|
57
|
+
### โ๏ธ SmartAcme Options
|
|
58
58
|
|
|
59
59
|
```typescript
|
|
60
60
|
interface ISmartAcmeOptions {
|
|
61
61
|
accountEmail: string; // ACME account email
|
|
62
62
|
accountPrivateKey?: string; // Optional account key (auto-generated if omitted)
|
|
63
63
|
certManager: ICertManager; // Certificate storage backend
|
|
64
|
-
environment: 'production' | 'integration'; //
|
|
64
|
+
environment: 'production' | 'integration'; // Let's Encrypt environment
|
|
65
65
|
challengeHandlers: IChallengeHandler[]; // At least one handler required
|
|
66
66
|
challengePriority?: string[]; // e.g. ['dns-01', 'http-01']
|
|
67
67
|
retryOptions?: { // Optional retry/backoff config
|
|
68
|
-
retries?: number;
|
|
69
|
-
factor?: number;
|
|
70
|
-
minTimeoutMs?: number;
|
|
71
|
-
maxTimeoutMs?: number;
|
|
68
|
+
retries?: number; // Default: 10
|
|
69
|
+
factor?: number; // Default: 4
|
|
70
|
+
minTimeoutMs?: number; // Default: 1000
|
|
71
|
+
maxTimeoutMs?: number; // Default: 60000
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
### Getting Certificates
|
|
76
|
+
### ๐ Getting Certificates
|
|
77
77
|
|
|
78
78
|
```typescript
|
|
79
79
|
// Standard certificate for a single domain
|
|
80
80
|
const cert = await smartAcme.getCertificateForDomain('example.com');
|
|
81
81
|
|
|
82
|
-
// Include wildcard
|
|
82
|
+
// Include wildcard coverage (requires DNS-01 handler)
|
|
83
|
+
// Issues a single cert covering example.com AND *.example.com
|
|
83
84
|
const certWithWildcard = await smartAcme.getCertificateForDomain('example.com', {
|
|
84
85
|
includeWildcard: true,
|
|
85
86
|
});
|
|
@@ -90,25 +91,32 @@ const wildcardCert = await smartAcme.getCertificateForDomain('*.example.com');
|
|
|
90
91
|
|
|
91
92
|
Certificates are automatically cached and reused when still valid. Renewal happens automatically when a certificate is within 10 days of expiration.
|
|
92
93
|
|
|
93
|
-
### Certificate Object
|
|
94
|
+
### ๐ฆ Certificate Object
|
|
94
95
|
|
|
95
|
-
The returned `SmartacmeCert` object has these properties:
|
|
96
|
+
The returned `SmartacmeCert` (also exported as `Cert`) object has these properties:
|
|
96
97
|
|
|
97
98
|
| Property | Type | Description |
|
|
98
99
|
|-------------|----------|--------------------------------------|
|
|
99
100
|
| `id` | `string` | Unique certificate identifier |
|
|
100
101
|
| `domainName`| `string` | Domain the cert is issued for |
|
|
101
|
-
| `publicKey` | `string` | PEM-encoded certificate
|
|
102
|
+
| `publicKey` | `string` | PEM-encoded certificate chain |
|
|
102
103
|
| `privateKey`| `string` | PEM-encoded private key |
|
|
103
104
|
| `csr` | `string` | Certificate Signing Request |
|
|
104
105
|
| `created` | `number` | Timestamp of creation |
|
|
105
106
|
| `validUntil`| `number` | Timestamp of expiration |
|
|
106
107
|
|
|
108
|
+
Useful methods:
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
cert.isStillValid(); // true if not expired
|
|
112
|
+
cert.shouldBeRenewed(); // true if expires within 10 days
|
|
113
|
+
```
|
|
114
|
+
|
|
107
115
|
## Certificate Managers
|
|
108
116
|
|
|
109
117
|
SmartAcme uses the `ICertManager` interface for pluggable certificate storage.
|
|
110
118
|
|
|
111
|
-
### MongoCertManager
|
|
119
|
+
### ๐๏ธ MongoCertManager
|
|
112
120
|
|
|
113
121
|
Persistent storage backed by MongoDB using `@push.rocks/smartdata`:
|
|
114
122
|
|
|
@@ -122,7 +130,7 @@ const certManager = new certmanagers.MongoCertManager({
|
|
|
122
130
|
});
|
|
123
131
|
```
|
|
124
132
|
|
|
125
|
-
### MemoryCertManager
|
|
133
|
+
### ๐งช MemoryCertManager
|
|
126
134
|
|
|
127
135
|
In-memory storage, ideal for testing or ephemeral workloads:
|
|
128
136
|
|
|
@@ -132,13 +140,12 @@ import { certmanagers } from '@push.rocks/smartacme';
|
|
|
132
140
|
const certManager = new certmanagers.MemoryCertManager();
|
|
133
141
|
```
|
|
134
142
|
|
|
135
|
-
### Custom Certificate Manager
|
|
143
|
+
### ๐ง Custom Certificate Manager
|
|
136
144
|
|
|
137
145
|
Implement the `ICertManager` interface for your own storage backend:
|
|
138
146
|
|
|
139
147
|
```typescript
|
|
140
|
-
import type { ICertManager } from '@push.rocks/smartacme';
|
|
141
|
-
import { Cert } from '@push.rocks/smartacme';
|
|
148
|
+
import type { ICertManager, Cert } from '@push.rocks/smartacme';
|
|
142
149
|
|
|
143
150
|
class RedisCertManager implements ICertManager {
|
|
144
151
|
async init(): Promise<void> { /* connect */ }
|
|
@@ -166,7 +173,7 @@ const cfAccount = new cloudflare.CloudflareAccount('YOUR_CF_TOKEN');
|
|
|
166
173
|
const dnsHandler = new handlers.Dns01Handler(cfAccount);
|
|
167
174
|
```
|
|
168
175
|
|
|
169
|
-
DNS-01 is required for wildcard certificates and works regardless of server accessibility.
|
|
176
|
+
DNS-01 is **required** for wildcard certificates and works regardless of server accessibility.
|
|
170
177
|
|
|
171
178
|
### ๐ Http01Webroot
|
|
172
179
|
|
|
@@ -197,12 +204,12 @@ app.use((req, res, next) => memHandler.handleRequest(req, res, next));
|
|
|
197
204
|
|
|
198
205
|
Perfect for serverless or container environments where filesystem access is limited.
|
|
199
206
|
|
|
200
|
-
### Custom Challenge Handler
|
|
207
|
+
### ๐ง Custom Challenge Handler
|
|
201
208
|
|
|
202
209
|
Implement `IChallengeHandler<T>` for custom challenge types:
|
|
203
210
|
|
|
204
211
|
```typescript
|
|
205
|
-
import type {
|
|
212
|
+
import type { handlers } from '@push.rocks/smartacme';
|
|
206
213
|
|
|
207
214
|
interface MyChallenge {
|
|
208
215
|
type: string;
|
|
@@ -210,28 +217,54 @@ interface MyChallenge {
|
|
|
210
217
|
keyAuthorization: string;
|
|
211
218
|
}
|
|
212
219
|
|
|
213
|
-
class MyHandler implements IChallengeHandler<MyChallenge> {
|
|
220
|
+
class MyHandler implements handlers.IChallengeHandler<MyChallenge> {
|
|
214
221
|
getSupportedTypes(): string[] { return ['http-01']; }
|
|
215
|
-
async prepare(ch: MyChallenge): Promise<void> { /*
|
|
216
|
-
async cleanup(ch: MyChallenge): Promise<void> { /*
|
|
222
|
+
async prepare(ch: MyChallenge): Promise<void> { /* set up challenge response */ }
|
|
223
|
+
async cleanup(ch: MyChallenge): Promise<void> { /* tear down */ }
|
|
217
224
|
async checkWetherDomainIsSupported(domain: string): Promise<boolean> { return true; }
|
|
218
225
|
}
|
|
219
226
|
```
|
|
220
227
|
|
|
228
|
+
## Error Handling
|
|
229
|
+
|
|
230
|
+
SmartAcme provides structured ACME error handling via the `AcmeError` class, which carries full RFC 8555 error information:
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
import { AcmeError } from '@push.rocks/smartacme/ts/acme/acme.classes.error.js';
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const cert = await smartAcme.getCertificateForDomain('example.com');
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (err instanceof AcmeError) {
|
|
239
|
+
console.log(err.status); // HTTP status code (e.g. 429)
|
|
240
|
+
console.log(err.type); // ACME error URN (e.g. 'urn:ietf:params:acme:error:rateLimited')
|
|
241
|
+
console.log(err.detail); // Human-readable message
|
|
242
|
+
console.log(err.subproblems); // Per-identifier sub-errors (RFC 8555 ยง6.7.1)
|
|
243
|
+
console.log(err.retryAfter); // Retry-After value in seconds
|
|
244
|
+
console.log(err.isRateLimited); // true for 429 or rateLimited type
|
|
245
|
+
console.log(err.isRetryable); // true for 429, 503, 5xx, badNonce; false for 403/404/409
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The built-in retry logic is **error-aware**: non-retryable errors (403, 404, 409) are thrown immediately without wasting retry attempts, and rate-limited responses respect the server's `Retry-After` header instead of using blind exponential backoff.
|
|
251
|
+
|
|
221
252
|
## Domain Matching
|
|
222
253
|
|
|
223
254
|
SmartAcme automatically maps subdomains to their base domain for certificate lookups:
|
|
224
255
|
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
256
|
+
```
|
|
257
|
+
subdomain.example.com โ certificate for example.com โ
|
|
258
|
+
*.example.com โ certificate for example.com โ
|
|
259
|
+
a.b.example.com โ not supported (4+ levels) โ
|
|
229
260
|
```
|
|
230
261
|
|
|
231
262
|
## Environment
|
|
232
263
|
|
|
233
|
-
|
|
234
|
-
|
|
264
|
+
| Environment | Description |
|
|
265
|
+
|----------------|-------------|
|
|
266
|
+
| `production` | Let's Encrypt production servers. Certificates are browser-trusted. [Rate limits](https://letsencrypt.org/docs/rate-limits/) apply. |
|
|
267
|
+
| `integration` | Let's Encrypt staging servers. No rate limits, but certificates are **not** browser-trusted. Use for testing. |
|
|
235
268
|
|
|
236
269
|
## Complete Example with HTTP-01
|
|
237
270
|
|
|
@@ -269,13 +302,20 @@ await smartAcme.stop();
|
|
|
269
302
|
server.close();
|
|
270
303
|
```
|
|
271
304
|
|
|
272
|
-
##
|
|
305
|
+
## Architecture
|
|
273
306
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
307
|
+
Under the hood, SmartAcme uses a fully custom RFC 8555-compliant ACME protocol implementation (no external ACME libraries). Key internal modules:
|
|
308
|
+
|
|
309
|
+
| Module | Purpose |
|
|
310
|
+
|--------|---------|
|
|
311
|
+
| `AcmeClient` | Top-level ACME facade โ orders, authorizations, finalization |
|
|
312
|
+
| `AcmeCrypto` | RSA key generation, JWK/JWS (RFC 7515/7638), CSR via `@peculiar/x509` |
|
|
313
|
+
| `AcmeHttpClient` | JWS-signed HTTP transport with nonce management and structured logging |
|
|
314
|
+
| `AcmeError` | Structured error class with type URN, subproblems, Retry-After, retryability |
|
|
315
|
+
| `AcmeOrderManager` | Order lifecycle โ create, poll, finalize, download certificate |
|
|
316
|
+
| `AcmeChallengeManager` | Key authorization computation and challenge completion |
|
|
277
317
|
|
|
278
|
-
|
|
318
|
+
All cryptographic operations use `node:crypto`. The only external crypto dependency is `@peculiar/x509` for CSR generation.
|
|
279
319
|
|
|
280
320
|
## License and Legal Information
|
|
281
321
|
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -96,4 +96,11 @@ export class AcmeClient {
|
|
|
96
96
|
async getCertificate(order: IAcmeOrder): Promise<string> {
|
|
97
97
|
return this.orderManager.getCertificate(order);
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Destroy HTTP transport to release sockets and allow process exit.
|
|
102
|
+
*/
|
|
103
|
+
destroy(): void {
|
|
104
|
+
this.httpClient.destroy();
|
|
105
|
+
}
|
|
99
106
|
}
|
|
@@ -17,11 +17,23 @@ export class AcmeHttpClient {
|
|
|
17
17
|
private nonce: string | null = null;
|
|
18
18
|
public kid: string | null = null;
|
|
19
19
|
private logger?: TAcmeLogger;
|
|
20
|
+
private httpsAgent: https.Agent;
|
|
21
|
+
private httpAgent: http.Agent;
|
|
20
22
|
|
|
21
23
|
constructor(directoryUrl: string, accountKeyPem: string, logger?: TAcmeLogger) {
|
|
22
24
|
this.directoryUrl = directoryUrl;
|
|
23
25
|
this.accountKeyPem = accountKeyPem;
|
|
24
26
|
this.logger = logger;
|
|
27
|
+
this.httpsAgent = new https.Agent({ keepAlive: false });
|
|
28
|
+
this.httpAgent = new http.Agent({ keepAlive: false });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Destroy HTTP agents to release sockets and allow process exit.
|
|
33
|
+
*/
|
|
34
|
+
destroy(): void {
|
|
35
|
+
this.httpsAgent.destroy();
|
|
36
|
+
this.httpAgent.destroy();
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
private log(level: string, message: string, data?: any): void {
|
|
@@ -186,6 +198,7 @@ export class AcmeHttpClient {
|
|
|
186
198
|
path: urlObj.pathname + urlObj.search,
|
|
187
199
|
method,
|
|
188
200
|
headers: requestHeaders,
|
|
201
|
+
agent: isHttps ? this.httpsAgent : this.httpAgent,
|
|
189
202
|
};
|
|
190
203
|
|
|
191
204
|
const req = lib.request(options, (res) => {
|
package/ts/index.ts
CHANGED
package/ts/plugins.ts
CHANGED
|
@@ -19,6 +19,7 @@ import * as smartnetwork from '@push.rocks/smartnetwork';
|
|
|
19
19
|
import * as smartunique from '@push.rocks/smartunique';
|
|
20
20
|
import * as smartstring from '@push.rocks/smartstring';
|
|
21
21
|
import * as smarttime from '@push.rocks/smarttime';
|
|
22
|
+
import * as taskbuffer from '@push.rocks/taskbuffer';
|
|
22
23
|
|
|
23
24
|
export {
|
|
24
25
|
lik,
|
|
@@ -30,6 +31,7 @@ export {
|
|
|
30
31
|
smartunique,
|
|
31
32
|
smartstring,
|
|
32
33
|
smarttime,
|
|
34
|
+
taskbuffer,
|
|
33
35
|
};
|
|
34
36
|
|
|
35
37
|
// @tsclass scope
|