From 52e1295fd232c1c2e2a39e56cad4e31d3a331af1 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 15 Feb 2026 20:43:06 +0000 Subject: [PATCH] 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). --- changelog.md | 8 ++ readme.md | 114 +++++++++++++++++++--------- ts/00_commitinfo_data.ts | 2 +- ts/acme/acme.classes.client.ts | 7 ++ ts/acme/acme.classes.http-client.ts | 13 ++++ ts/smartacme.classes.smartacme.ts | 8 ++ 6 files changed, 114 insertions(+), 38 deletions(-) diff --git a/changelog.md b/changelog.md index 736292c..b9cc654 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # 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) Replace external acme-client with a built-in RFC8555-compliant ACME implementation and update public APIs accordingly diff --git a/readme.md b/readme.md index e067a9e..0c306e4 100644 --- a/readme.md +++ b/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 { /* 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` 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 { +class MyHandler implements handlers.IChallengeHandler { getSupportedTypes(): string[] { return ['http-01']; } - async prepare(ch: MyChallenge): Promise { /* ... */ } - async cleanup(ch: MyChallenge): Promise { /* ... */ } + async prepare(ch: MyChallenge): Promise { /* set up challenge response */ } + async cleanup(ch: MyChallenge): Promise { /* tear down */ } async checkWetherDomainIsSupported(domain: string): Promise { 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 diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index db23320..0fb3bb9 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { 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.' } diff --git a/ts/acme/acme.classes.client.ts b/ts/acme/acme.classes.client.ts index 5ede3d9..168dd10 100644 --- a/ts/acme/acme.classes.client.ts +++ b/ts/acme/acme.classes.client.ts @@ -96,4 +96,11 @@ export class AcmeClient { async getCertificate(order: IAcmeOrder): Promise { return this.orderManager.getCertificate(order); } + + /** + * Destroy HTTP transport to release sockets and allow process exit. + */ + destroy(): void { + this.httpClient.destroy(); + } } diff --git a/ts/acme/acme.classes.http-client.ts b/ts/acme/acme.classes.http-client.ts index 4f898e5..9d1dd42 100644 --- a/ts/acme/acme.classes.http-client.ts +++ b/ts/acme/acme.classes.http-client.ts @@ -17,11 +17,23 @@ export class AcmeHttpClient { private nonce: string | null = null; public kid: string | null = null; private logger?: TAcmeLogger; + private httpsAgent: https.Agent; + private httpAgent: http.Agent; constructor(directoryUrl: string, accountKeyPem: string, logger?: TAcmeLogger) { this.directoryUrl = directoryUrl; this.accountKeyPem = accountKeyPem; 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 { @@ -186,6 +198,7 @@ export class AcmeHttpClient { path: urlObj.pathname + urlObj.search, method, headers: requestHeaders, + agent: isHttps ? this.httpsAgent : this.httpAgent, }; const req = lib.request(options, (res) => { diff --git a/ts/smartacme.classes.smartacme.ts b/ts/smartacme.classes.smartacme.ts index 9642a8b..9507c92 100644 --- a/ts/smartacme.classes.smartacme.ts +++ b/ts/smartacme.classes.smartacme.ts @@ -169,6 +169,14 @@ export class SmartAcme { process.removeListener('SIGTERM', this.boundSigtermHandler); 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') { await (this.certmanager as any).close(); }