fix(acme-http-client): Destroy keep-alive HTTP agents and DNS client on shutdown to allow process exit; add destroy() on AcmeHttpClient and AcmeClient, wire agents into requests, and call client/smartdns destroy during SmartAcme.stop; documentation clarifications and expanded README (error handling, examples, default retry values).
This commit is contained in:
114
readme.md
114
readme.md
@@ -1,6 +1,6 @@
|
||||
# @push.rocks/smartacme
|
||||
|
||||
A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power. 🔒
|
||||
A TypeScript-based ACME client for Let's Encrypt certificate management with a focus on simplicity and power. 🔒
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -16,9 +16,9 @@ Ensure your project uses TypeScript and ECMAScript Modules (ESM).
|
||||
|
||||
## Usage
|
||||
|
||||
`@push.rocks/smartacme` automates the full ACME certificate lifecycle — obtaining, renewing, and storing SSL/TLS certificates from Let's Encrypt. It supports pluggable challenge handlers (DNS-01, HTTP-01) and pluggable certificate storage backends (MongoDB, in-memory, or your own).
|
||||
`@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.
|
||||
|
||||
### Quick Start
|
||||
### 🚀 Quick Start
|
||||
|
||||
```typescript
|
||||
import { SmartAcme, certmanagers, handlers } from '@push.rocks/smartacme';
|
||||
@@ -47,39 +47,40 @@ await smartAcme.start();
|
||||
|
||||
// 4. Get a certificate
|
||||
const cert = await smartAcme.getCertificateForDomain('example.com');
|
||||
console.log(cert.publicKey); // PEM certificate
|
||||
console.log(cert.publicKey); // PEM certificate chain
|
||||
console.log(cert.privateKey); // PEM private key
|
||||
|
||||
// 5. Clean up
|
||||
await smartAcme.stop();
|
||||
```
|
||||
|
||||
### SmartAcme Options
|
||||
### ⚙️ SmartAcme Options
|
||||
|
||||
```typescript
|
||||
interface ISmartAcmeOptions {
|
||||
accountEmail: string; // ACME account email
|
||||
accountPrivateKey?: string; // Optional account key (auto-generated if omitted)
|
||||
certManager: ICertManager; // Certificate storage backend
|
||||
environment: 'production' | 'integration'; // LetsEncrypt environment
|
||||
environment: 'production' | 'integration'; // Let's Encrypt environment
|
||||
challengeHandlers: IChallengeHandler[]; // At least one handler required
|
||||
challengePriority?: string[]; // e.g. ['dns-01', 'http-01']
|
||||
retryOptions?: { // Optional retry/backoff config
|
||||
retries?: number;
|
||||
factor?: number;
|
||||
minTimeoutMs?: number;
|
||||
maxTimeoutMs?: number;
|
||||
retries?: number; // Default: 10
|
||||
factor?: number; // Default: 4
|
||||
minTimeoutMs?: number; // Default: 1000
|
||||
maxTimeoutMs?: number; // Default: 60000
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Getting Certificates
|
||||
### 📜 Getting Certificates
|
||||
|
||||
```typescript
|
||||
// Standard certificate for a single domain
|
||||
const cert = await smartAcme.getCertificateForDomain('example.com');
|
||||
|
||||
// Include wildcard certificate (requires DNS-01 handler)
|
||||
// Include wildcard coverage (requires DNS-01 handler)
|
||||
// Issues a single cert covering example.com AND *.example.com
|
||||
const certWithWildcard = await smartAcme.getCertificateForDomain('example.com', {
|
||||
includeWildcard: true,
|
||||
});
|
||||
@@ -90,25 +91,32 @@ const wildcardCert = await smartAcme.getCertificateForDomain('*.example.com');
|
||||
|
||||
Certificates are automatically cached and reused when still valid. Renewal happens automatically when a certificate is within 10 days of expiration.
|
||||
|
||||
### Certificate Object
|
||||
### 📦 Certificate Object
|
||||
|
||||
The returned `SmartacmeCert` object has these properties:
|
||||
The returned `SmartacmeCert` (also exported as `Cert`) object has these properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
|-------------|----------|--------------------------------------|
|
||||
| `id` | `string` | Unique certificate identifier |
|
||||
| `domainName`| `string` | Domain the cert is issued for |
|
||||
| `publicKey` | `string` | PEM-encoded certificate |
|
||||
| `publicKey` | `string` | PEM-encoded certificate chain |
|
||||
| `privateKey`| `string` | PEM-encoded private key |
|
||||
| `csr` | `string` | Certificate Signing Request |
|
||||
| `created` | `number` | Timestamp of creation |
|
||||
| `validUntil`| `number` | Timestamp of expiration |
|
||||
|
||||
Useful methods:
|
||||
|
||||
```typescript
|
||||
cert.isStillValid(); // true if not expired
|
||||
cert.shouldBeRenewed(); // true if expires within 10 days
|
||||
```
|
||||
|
||||
## Certificate Managers
|
||||
|
||||
SmartAcme uses the `ICertManager` interface for pluggable certificate storage.
|
||||
|
||||
### MongoCertManager
|
||||
### 🗄️ MongoCertManager
|
||||
|
||||
Persistent storage backed by MongoDB using `@push.rocks/smartdata`:
|
||||
|
||||
@@ -122,7 +130,7 @@ const certManager = new certmanagers.MongoCertManager({
|
||||
});
|
||||
```
|
||||
|
||||
### MemoryCertManager
|
||||
### 🧪 MemoryCertManager
|
||||
|
||||
In-memory storage, ideal for testing or ephemeral workloads:
|
||||
|
||||
@@ -132,13 +140,12 @@ import { certmanagers } from '@push.rocks/smartacme';
|
||||
const certManager = new certmanagers.MemoryCertManager();
|
||||
```
|
||||
|
||||
### Custom Certificate Manager
|
||||
### 🔧 Custom Certificate Manager
|
||||
|
||||
Implement the `ICertManager` interface for your own storage backend:
|
||||
|
||||
```typescript
|
||||
import type { ICertManager } from '@push.rocks/smartacme';
|
||||
import { Cert } from '@push.rocks/smartacme';
|
||||
import type { ICertManager, Cert } from '@push.rocks/smartacme';
|
||||
|
||||
class RedisCertManager implements ICertManager {
|
||||
async init(): Promise<void> { /* connect */ }
|
||||
@@ -166,7 +173,7 @@ const cfAccount = new cloudflare.CloudflareAccount('YOUR_CF_TOKEN');
|
||||
const dnsHandler = new handlers.Dns01Handler(cfAccount);
|
||||
```
|
||||
|
||||
DNS-01 is required for wildcard certificates and works regardless of server accessibility.
|
||||
DNS-01 is **required** for wildcard certificates and works regardless of server accessibility.
|
||||
|
||||
### 📁 Http01Webroot
|
||||
|
||||
@@ -197,12 +204,12 @@ app.use((req, res, next) => memHandler.handleRequest(req, res, next));
|
||||
|
||||
Perfect for serverless or container environments where filesystem access is limited.
|
||||
|
||||
### Custom Challenge Handler
|
||||
### 🔧 Custom Challenge Handler
|
||||
|
||||
Implement `IChallengeHandler<T>` for custom challenge types:
|
||||
|
||||
```typescript
|
||||
import type { IChallengeHandler } from '@push.rocks/smartacme';
|
||||
import type { handlers } from '@push.rocks/smartacme';
|
||||
|
||||
interface MyChallenge {
|
||||
type: string;
|
||||
@@ -210,28 +217,54 @@ interface MyChallenge {
|
||||
keyAuthorization: string;
|
||||
}
|
||||
|
||||
class MyHandler implements IChallengeHandler<MyChallenge> {
|
||||
class MyHandler implements handlers.IChallengeHandler<MyChallenge> {
|
||||
getSupportedTypes(): string[] { return ['http-01']; }
|
||||
async prepare(ch: MyChallenge): Promise<void> { /* ... */ }
|
||||
async cleanup(ch: MyChallenge): Promise<void> { /* ... */ }
|
||||
async prepare(ch: MyChallenge): Promise<void> { /* set up challenge response */ }
|
||||
async cleanup(ch: MyChallenge): Promise<void> { /* tear down */ }
|
||||
async checkWetherDomainIsSupported(domain: string): Promise<boolean> { return true; }
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
SmartAcme provides structured ACME error handling via the `AcmeError` class, which carries full RFC 8555 error information:
|
||||
|
||||
```typescript
|
||||
import { AcmeError } from '@push.rocks/smartacme/ts/acme/acme.classes.error.js';
|
||||
|
||||
try {
|
||||
const cert = await smartAcme.getCertificateForDomain('example.com');
|
||||
} catch (err) {
|
||||
if (err instanceof AcmeError) {
|
||||
console.log(err.status); // HTTP status code (e.g. 429)
|
||||
console.log(err.type); // ACME error URN (e.g. 'urn:ietf:params:acme:error:rateLimited')
|
||||
console.log(err.detail); // Human-readable message
|
||||
console.log(err.subproblems); // Per-identifier sub-errors (RFC 8555 §6.7.1)
|
||||
console.log(err.retryAfter); // Retry-After value in seconds
|
||||
console.log(err.isRateLimited); // true for 429 or rateLimited type
|
||||
console.log(err.isRetryable); // true for 429, 503, 5xx, badNonce; false for 403/404/409
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Domain Matching
|
||||
|
||||
SmartAcme automatically maps subdomains to their base domain for certificate lookups:
|
||||
|
||||
```typescript
|
||||
// subdomain.example.com → certificate for example.com
|
||||
// *.example.com → certificate for example.com
|
||||
// a.b.example.com → not supported (4+ level domains)
|
||||
```
|
||||
subdomain.example.com → certificate for example.com ✅
|
||||
*.example.com → certificate for example.com ✅
|
||||
a.b.example.com → not supported (4+ levels) ❌
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
- **`production`** — Uses LetsEncrypt production servers. Rate limits apply.
|
||||
- **`integration`** — Uses LetsEncrypt staging servers. No rate limits, but certificates are not trusted by browsers. Use for testing.
|
||||
| Environment | Description |
|
||||
|----------------|-------------|
|
||||
| `production` | Let's Encrypt production servers. Certificates are browser-trusted. [Rate limits](https://letsencrypt.org/docs/rate-limits/) apply. |
|
||||
| `integration` | Let's Encrypt staging servers. No rate limits, but certificates are **not** browser-trusted. Use for testing. |
|
||||
|
||||
## Complete Example with HTTP-01
|
||||
|
||||
@@ -269,13 +302,20 @@ await smartAcme.stop();
|
||||
server.close();
|
||||
```
|
||||
|
||||
## Testing
|
||||
## Architecture
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
Under the hood, SmartAcme uses a fully custom RFC 8555-compliant ACME protocol implementation (no external ACME libraries). Key internal modules:
|
||||
|
||||
Tests use `@git.zone/tstest` with the tapbundle assertion library.
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `AcmeClient` | Top-level ACME facade — orders, authorizations, finalization |
|
||||
| `AcmeCrypto` | RSA key generation, JWK/JWS (RFC 7515/7638), CSR via `@peculiar/x509` |
|
||||
| `AcmeHttpClient` | JWS-signed HTTP transport with nonce management and structured logging |
|
||||
| `AcmeError` | Structured error class with type URN, subproblems, Retry-After, retryability |
|
||||
| `AcmeOrderManager` | Order lifecycle — create, poll, finalize, download certificate |
|
||||
| `AcmeChallengeManager` | Key authorization computation and challenge completion |
|
||||
|
||||
All cryptographic operations use `node:crypto`. The only external crypto dependency is `@peculiar/x509` for CSR generation.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
|
||||
Reference in New Issue
Block a user