Compare commits

...

2 Commits

Author SHA1 Message Date
68178366d5 v9.0.1
Some checks failed
Default (tags) / security (push) Successful in 1m48s
Default (tags) / test (push) Failing after 1m40s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-15 20:43:06 +00:00
52e1295fd2 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). 2026-02-15 20:43:06 +00:00
7 changed files with 115 additions and 39 deletions

View File

@@ -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 (Lets 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

View File

@@ -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
View File

@@ -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

View File

@@ -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.'
} }

View File

@@ -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();
}
} }

View File

@@ -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) => {

View File

@@ -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();
} }