Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68178366d5 | |||
| 52e1295fd2 |
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-15 - 9.0.1 - 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).
|
||||||
|
|
||||||
|
- ts/acme/acme.classes.http-client.ts: added per-protocol http/https agents (keepAlive: false), use agent for outgoing requests, and added destroy() to explicitly destroy agents and free sockets.
|
||||||
|
- ts/acme/acme.classes.client.ts: added destroy() that forwards to the HTTP client to allow transport cleanup.
|
||||||
|
- ts/smartacme.classes.smartacme.ts: SmartAcme.stop now calls client.destroy() and smartdns.destroy() (when present) to ensure child processes and sockets are terminated before exit; also ensures certmanager.close() is awaited.
|
||||||
|
- readme.md: documentation improvements and clarifications (Let’s Encrypt spelling, added RFC 8555 compliance note, error handling / AcmeError usage examples, default retry parameter docs, UI/emoji improvements, and other wording/formatting updates).
|
||||||
|
|
||||||
## 2026-02-15 - 9.0.0 - BREAKING CHANGE(acme)
|
## 2026-02-15 - 9.0.0 - BREAKING CHANGE(acme)
|
||||||
Replace external acme-client with a built-in RFC8555-compliant ACME implementation and update public APIs accordingly
|
Replace external acme-client with a built-in RFC8555-compliant ACME implementation and update public APIs accordingly
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartacme",
|
"name": "@push.rocks/smartacme",
|
||||||
"version": "9.0.0",
|
"version": "9.0.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.",
|
"description": "A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
|
|||||||
114
readme.md
114
readme.md
@@ -1,6 +1,6 @@
|
|||||||
# @push.rocks/smartacme
|
# @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
|
## Issue Reporting and Security
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@ Ensure your project uses TypeScript and ECMAScript Modules (ESM).
|
|||||||
|
|
||||||
## Usage
|
## 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
|
```typescript
|
||||||
import { SmartAcme, certmanagers, handlers } from '@push.rocks/smartacme';
|
import { SmartAcme, certmanagers, handlers } from '@push.rocks/smartacme';
|
||||||
@@ -47,39 +47,40 @@ await smartAcme.start();
|
|||||||
|
|
||||||
// 4. Get a certificate
|
// 4. Get a certificate
|
||||||
const cert = await smartAcme.getCertificateForDomain('example.com');
|
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
|
console.log(cert.privateKey); // PEM private key
|
||||||
|
|
||||||
// 5. Clean up
|
// 5. Clean up
|
||||||
await smartAcme.stop();
|
await smartAcme.stop();
|
||||||
```
|
```
|
||||||
|
|
||||||
### SmartAcme Options
|
### ⚙️ SmartAcme Options
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface ISmartAcmeOptions {
|
interface ISmartAcmeOptions {
|
||||||
accountEmail: string; // ACME account email
|
accountEmail: string; // ACME account email
|
||||||
accountPrivateKey?: string; // Optional account key (auto-generated if omitted)
|
accountPrivateKey?: string; // Optional account key (auto-generated if omitted)
|
||||||
certManager: ICertManager; // Certificate storage backend
|
certManager: ICertManager; // Certificate storage backend
|
||||||
environment: 'production' | 'integration'; // LetsEncrypt environment
|
environment: 'production' | 'integration'; // Let's Encrypt environment
|
||||||
challengeHandlers: IChallengeHandler[]; // At least one handler required
|
challengeHandlers: IChallengeHandler[]; // At least one handler required
|
||||||
challengePriority?: string[]; // e.g. ['dns-01', 'http-01']
|
challengePriority?: string[]; // e.g. ['dns-01', 'http-01']
|
||||||
retryOptions?: { // Optional retry/backoff config
|
retryOptions?: { // Optional retry/backoff config
|
||||||
retries?: number;
|
retries?: number; // Default: 10
|
||||||
factor?: number;
|
factor?: number; // Default: 4
|
||||||
minTimeoutMs?: number;
|
minTimeoutMs?: number; // Default: 1000
|
||||||
maxTimeoutMs?: number;
|
maxTimeoutMs?: number; // Default: 60000
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Getting Certificates
|
### 📜 Getting Certificates
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Standard certificate for a single domain
|
// Standard certificate for a single domain
|
||||||
const cert = await smartAcme.getCertificateForDomain('example.com');
|
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', {
|
const certWithWildcard = await smartAcme.getCertificateForDomain('example.com', {
|
||||||
includeWildcard: true,
|
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.
|
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 |
|
| Property | Type | Description |
|
||||||
|-------------|----------|--------------------------------------|
|
|-------------|----------|--------------------------------------|
|
||||||
| `id` | `string` | Unique certificate identifier |
|
| `id` | `string` | Unique certificate identifier |
|
||||||
| `domainName`| `string` | Domain the cert is issued for |
|
| `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 |
|
| `privateKey`| `string` | PEM-encoded private key |
|
||||||
| `csr` | `string` | Certificate Signing Request |
|
| `csr` | `string` | Certificate Signing Request |
|
||||||
| `created` | `number` | Timestamp of creation |
|
| `created` | `number` | Timestamp of creation |
|
||||||
| `validUntil`| `number` | Timestamp of expiration |
|
| `validUntil`| `number` | Timestamp of expiration |
|
||||||
|
|
||||||
|
Useful methods:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
cert.isStillValid(); // true if not expired
|
||||||
|
cert.shouldBeRenewed(); // true if expires within 10 days
|
||||||
|
```
|
||||||
|
|
||||||
## Certificate Managers
|
## Certificate Managers
|
||||||
|
|
||||||
SmartAcme uses the `ICertManager` interface for pluggable certificate storage.
|
SmartAcme uses the `ICertManager` interface for pluggable certificate storage.
|
||||||
|
|
||||||
### MongoCertManager
|
### 🗄️ MongoCertManager
|
||||||
|
|
||||||
Persistent storage backed by MongoDB using `@push.rocks/smartdata`:
|
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:
|
In-memory storage, ideal for testing or ephemeral workloads:
|
||||||
|
|
||||||
@@ -132,13 +140,12 @@ import { certmanagers } from '@push.rocks/smartacme';
|
|||||||
const certManager = new certmanagers.MemoryCertManager();
|
const certManager = new certmanagers.MemoryCertManager();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Custom Certificate Manager
|
### 🔧 Custom Certificate Manager
|
||||||
|
|
||||||
Implement the `ICertManager` interface for your own storage backend:
|
Implement the `ICertManager` interface for your own storage backend:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type { ICertManager } from '@push.rocks/smartacme';
|
import type { ICertManager, Cert } from '@push.rocks/smartacme';
|
||||||
import { Cert } from '@push.rocks/smartacme';
|
|
||||||
|
|
||||||
class RedisCertManager implements ICertManager {
|
class RedisCertManager implements ICertManager {
|
||||||
async init(): Promise<void> { /* connect */ }
|
async init(): Promise<void> { /* connect */ }
|
||||||
@@ -166,7 +173,7 @@ const cfAccount = new cloudflare.CloudflareAccount('YOUR_CF_TOKEN');
|
|||||||
const dnsHandler = new handlers.Dns01Handler(cfAccount);
|
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
|
### 📁 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.
|
Perfect for serverless or container environments where filesystem access is limited.
|
||||||
|
|
||||||
### Custom Challenge Handler
|
### 🔧 Custom Challenge Handler
|
||||||
|
|
||||||
Implement `IChallengeHandler<T>` for custom challenge types:
|
Implement `IChallengeHandler<T>` for custom challenge types:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type { IChallengeHandler } from '@push.rocks/smartacme';
|
import type { handlers } from '@push.rocks/smartacme';
|
||||||
|
|
||||||
interface MyChallenge {
|
interface MyChallenge {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -210,28 +217,54 @@ interface MyChallenge {
|
|||||||
keyAuthorization: string;
|
keyAuthorization: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyHandler implements IChallengeHandler<MyChallenge> {
|
class MyHandler implements handlers.IChallengeHandler<MyChallenge> {
|
||||||
getSupportedTypes(): string[] { return ['http-01']; }
|
getSupportedTypes(): string[] { return ['http-01']; }
|
||||||
async prepare(ch: MyChallenge): Promise<void> { /* ... */ }
|
async prepare(ch: MyChallenge): Promise<void> { /* set up challenge response */ }
|
||||||
async cleanup(ch: MyChallenge): Promise<void> { /* ... */ }
|
async cleanup(ch: MyChallenge): Promise<void> { /* tear down */ }
|
||||||
async checkWetherDomainIsSupported(domain: string): Promise<boolean> { return true; }
|
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
|
## Domain Matching
|
||||||
|
|
||||||
SmartAcme automatically maps subdomains to their base domain for certificate lookups:
|
SmartAcme automatically maps subdomains to their base domain for certificate lookups:
|
||||||
|
|
||||||
```typescript
|
```
|
||||||
// subdomain.example.com → certificate for example.com
|
subdomain.example.com → certificate for example.com ✅
|
||||||
// *.example.com → certificate for example.com
|
*.example.com → certificate for example.com ✅
|
||||||
// a.b.example.com → not supported (4+ level domains)
|
a.b.example.com → not supported (4+ levels) ❌
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
- **`production`** — Uses LetsEncrypt production servers. Rate limits apply.
|
| Environment | Description |
|
||||||
- **`integration`** — Uses LetsEncrypt staging servers. No rate limits, but certificates are not trusted by browsers. Use for testing.
|
|----------------|-------------|
|
||||||
|
| `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
|
## Complete Example with HTTP-01
|
||||||
|
|
||||||
@@ -269,13 +302,20 @@ await smartAcme.stop();
|
|||||||
server.close();
|
server.close();
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Architecture
|
||||||
|
|
||||||
```bash
|
Under the hood, SmartAcme uses a fully custom RFC 8555-compliant ACME protocol implementation (no external ACME libraries). Key internal modules:
|
||||||
pnpm test
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
## License and Legal Information
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartacme',
|
name: '@push.rocks/smartacme',
|
||||||
version: '9.0.0',
|
version: '9.0.1',
|
||||||
description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.'
|
description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,4 +96,11 @@ export class AcmeClient {
|
|||||||
async getCertificate(order: IAcmeOrder): Promise<string> {
|
async getCertificate(order: IAcmeOrder): Promise<string> {
|
||||||
return this.orderManager.getCertificate(order);
|
return this.orderManager.getCertificate(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy HTTP transport to release sockets and allow process exit.
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.httpClient.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,23 @@ export class AcmeHttpClient {
|
|||||||
private nonce: string | null = null;
|
private nonce: string | null = null;
|
||||||
public kid: string | null = null;
|
public kid: string | null = null;
|
||||||
private logger?: TAcmeLogger;
|
private logger?: TAcmeLogger;
|
||||||
|
private httpsAgent: https.Agent;
|
||||||
|
private httpAgent: http.Agent;
|
||||||
|
|
||||||
constructor(directoryUrl: string, accountKeyPem: string, logger?: TAcmeLogger) {
|
constructor(directoryUrl: string, accountKeyPem: string, logger?: TAcmeLogger) {
|
||||||
this.directoryUrl = directoryUrl;
|
this.directoryUrl = directoryUrl;
|
||||||
this.accountKeyPem = accountKeyPem;
|
this.accountKeyPem = accountKeyPem;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
|
this.httpsAgent = new https.Agent({ keepAlive: false });
|
||||||
|
this.httpAgent = new http.Agent({ keepAlive: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy HTTP agents to release sockets and allow process exit.
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.httpsAgent.destroy();
|
||||||
|
this.httpAgent.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
private log(level: string, message: string, data?: any): void {
|
private log(level: string, message: string, data?: any): void {
|
||||||
@@ -186,6 +198,7 @@ export class AcmeHttpClient {
|
|||||||
path: urlObj.pathname + urlObj.search,
|
path: urlObj.pathname + urlObj.search,
|
||||||
method,
|
method,
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
|
agent: isHttps ? this.httpsAgent : this.httpAgent,
|
||||||
};
|
};
|
||||||
|
|
||||||
const req = lib.request(options, (res) => {
|
const req = lib.request(options, (res) => {
|
||||||
|
|||||||
@@ -169,6 +169,14 @@ export class SmartAcme {
|
|||||||
process.removeListener('SIGTERM', this.boundSigtermHandler);
|
process.removeListener('SIGTERM', this.boundSigtermHandler);
|
||||||
this.boundSigtermHandler = null;
|
this.boundSigtermHandler = null;
|
||||||
}
|
}
|
||||||
|
// Destroy ACME HTTP transport (closes keep-alive sockets)
|
||||||
|
if (this.client) {
|
||||||
|
this.client.destroy();
|
||||||
|
}
|
||||||
|
// Destroy DNS client (kills Rust bridge child process if spawned)
|
||||||
|
if (this.smartdns) {
|
||||||
|
this.smartdns.destroy();
|
||||||
|
}
|
||||||
if (this.certmanager && typeof (this.certmanager as any).close === 'function') {
|
if (this.certmanager && typeof (this.certmanager as any).close === 'function') {
|
||||||
await (this.certmanager as any).close();
|
await (this.certmanager as any).close();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user