Compare commits

...

4 Commits

Author SHA1 Message Date
876d876661 7.2.2
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Failing after 54s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-05-01 11:33:55 +00:00
ae212c53d5 fix(readme): Update readme documentation: switch installation instructions to pnpm and clarify usage with MongoCertManager and updated SmartAcme options 2025-05-01 11:33:55 +00:00
b9866c2ced 7.2.1
Some checks failed
Default (tags) / security (push) Successful in 33s
Default (tags) / test (push) Failing after 53s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-05-01 09:28:10 +00:00
c863c7295d fix(smartacme): Centralize interest map coordination and remove redundant interestMap from cert managers 2025-05-01 09:28:10 +00:00
7 changed files with 91 additions and 107 deletions

View File

@ -1,5 +1,20 @@
# Changelog # Changelog
## 2025-05-01 - 7.2.2 - fix(readme)
Update readme documentation: switch installation instructions to pnpm and clarify usage with MongoCertManager and updated SmartAcme options
- Replaced npm/yarn commands with pnpm commands for installation and testing.
- Added guidance to ensure the project is set up for TypeScript and ECMAScript Modules.
- Updated usage examples to include initialization of MongoCertManager instead of legacy mongoDescriptor.
- Revised challenge handlers examples to reference the current API signatures.
## 2025-05-01 - 7.2.1 - fix(smartacme)
Centralize interest map coordination and remove redundant interestMap from cert managers
- Removed interestMap property and related logic from MemoryCertManager and MongoCertManager
- Refactored SmartAcme to instantiate its own interestMap for coordinating certificate requests
- Updated getCertificateForDomain to use the new interestMap for checking and adding certificate interests
## 2025-05-01 - 7.2.0 - feat(core) ## 2025-05-01 - 7.2.0 - feat(core)
Refactor SmartAcme core to centralize interest coordination and update dependencies Refactor SmartAcme core to centralize interest coordination and update dependencies

View File

@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartacme", "name": "@push.rocks/smartacme",
"version": "7.2.0", "version": "7.2.2",
"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",

131
readme.md
View File

@ -4,19 +4,26 @@ A TypeScript-based ACME client with an easy yet powerful interface for LetsEncry
## Install ## Install
To install `@push.rocks/smartacme`, you can use npm or yarn. Run one of the following commands in your project directory: Using pnpm as the package manager:
```bash ```bash
npm install @push.rocks/smartacme --save pnpm add @push.rocks/smartacme
``` ```
or Ensure your project is set up to use TypeScript and ECMAScript Modules (ESM).
## Running Tests
Tests are written using `@push.rocks/tapbundle` and can be run with:
```bash ```bash
yarn add @push.rocks/smartacme pnpm test
``` ```
Make sure your project is set up to use TypeScript and supports ECMAScript Modules (ESM). To run a specific test file:
```bash
tsx test/<test-file>.ts
```
## Usage ## Usage
@ -42,28 +49,31 @@ Ensure your project includes the necessary TypeScript configuration and dependen
Start by importing the `SmartAcme` class and any built-in handlers you plan to use. For example, to use DNS-01 via Cloudflare: Start by importing the `SmartAcme` class and any built-in handlers you plan to use. For example, to use DNS-01 via Cloudflare:
```typescript ```typescript
import { SmartAcme } from '@push.rocks/smartacme'; import { SmartAcme, MongoCertManager } from '@push.rocks/smartacme';
import * as cloudflare from '@apiclient.xyz/cloudflare'; import * as cloudflare from '@apiclient.xyz/cloudflare';
import { Dns01Handler } from '@push.rocks/smartacme/ts/handlers/Dns01Handler.js'; import { Dns01Handler } from '@push.rocks/smartacme/ts/handlers/Dns01Handler.js';
// Create a Cloudflare account client with your API token // Create a Cloudflare account client with your API token
const cfAccount = new cloudflare.CloudflareAccount('YOUR_CF_TOKEN'); const cfAccount = new cloudflare.CloudflareAccount('YOUR_CF_TOKEN');
// Instantiate SmartAcme with one or more ACME challenge handlers // Initialize a certificate manager (e.g., MongoDB)
const certManager = new MongoCertManager({
mongoDbUrl: 'mongodb://yourmongoURL',
mongoDbName: 'yourDbName',
mongoDbPass: 'yourDbPassword',
});
// Instantiate SmartAcme with the certManager and challenge handlers
const smartAcmeInstance = new SmartAcme({ const smartAcmeInstance = new SmartAcme({
accountEmail: 'youremail@example.com', accountEmail: 'youremail@example.com',
mongoDescriptor: { certManager,
mongoDbUrl: 'mongodb://yourmongoURL',
mongoDbName: 'yourDbName',
mongoDbPass: 'yourDbPassword',
},
environment: 'integration', // 'production' to request real certificates environment: 'integration', // 'production' to request real certificates
retryOptions: {}, // optional retry/backoff settings retryOptions: {}, // optional retry/backoff settings
challengeHandlers: [ challengeHandlers: [ // pluggable ACME challenge handlers
new Dns01Handler(cfAccount), new Dns01Handler(cfAccount),
// you can add more handlers, e.g. Http01Webroot // add more handlers as needed (e.g., Http01Webroot, Http01MemoryHandler)
], ],
challengePriority: ['dns-01'], // optional ordering of challenge types challengePriority: ['dns-01'], // optional challenge ordering
}); });
``` ```
@ -91,14 +101,16 @@ SmartAcme uses pluggable ACME challenge handlers (see built-in handlers below) t
### Managing Certificates ### Managing Certificates
The library automatically handles fetching, renewing, and storing your certificates in a MongoDB database specified in your configuration. Ensure your MongoDB instance is accessible and properly configured for use with SmartAcme. The library automatically handles fetching, renewing, and storing your certificates in a MongoDB database specified via a certificate manager. Ensure your MongoDB instance is accessible and properly configured for use with SmartAcme.
```typescript ```typescript
const mongoDescriptor = { import { MongoCertManager } from '@push.rocks/smartacme';
const certManager = new MongoCertManager({
mongoDbUrl: 'mongodb://yourmongoURL', mongoDbUrl: 'mongodb://yourmongoURL',
mongoDbName: 'yourDbName', mongoDbName: 'yourDbName',
mongoDbPass: 'yourDbPassword', mongoDbPass: 'yourDbPassword',
}; });
``` ```
### Environmental Considerations ### Environmental Considerations
@ -110,23 +122,27 @@ When creating an instance of `SmartAcme`, you can specify an `environment` optio
Below is a complete example demonstrating how to use `@push.rocks/smartacme` to obtain and manage an ACME certificate with Let's Encrypt using a DNS-01 handler: Below is a complete example demonstrating how to use `@push.rocks/smartacme` to obtain and manage an ACME certificate with Let's Encrypt using a DNS-01 handler:
```typescript ```typescript
import { SmartAcme } from '@push.rocks/smartacme'; import { SmartAcme, MongoCertManager } from '@push.rocks/smartacme';
import * as cloudflare from '@apiclient.xyz/cloudflare'; import * as cloudflare from '@apiclient.xyz/cloudflare';
import { Qenv } from '@push.rocks/qenv'; import { Qenv } from '@push.rocks/qenv';
import { Dns01Handler } from '@push.rocks/smartacme/ts/handlers/Dns01Handler.js';
const qenv = new Qenv('./', './.nogit/'); const qenv = new Qenv('./', './.nogit/');
const cloudflareAccount = new cloudflare.CloudflareAccount(qenv.getEnvVarOnDemand('CF_TOKEN')); const cloudflareAccount = new cloudflare.CloudflareAccount(qenv.getEnvVarOnDemand('CF_TOKEN'));
async function main() { async function main() {
// Initialize MongoDB certificate manager
const certManager = new MongoCertManager({
mongoDbUrl: qenv.getEnvVarRequired('MONGODB_URL'),
mongoDbName: qenv.getEnvVarRequired('MONGODB_DATABASE'),
mongoDbPass: qenv.getEnvVarRequired('MONGODB_PASSWORD'),
});
const smartAcmeInstance = new SmartAcme({ const smartAcmeInstance = new SmartAcme({
accountEmail: 'youremail@example.com', accountEmail: 'youremail@example.com',
mongoDescriptor: { certManager,
mongoDbUrl: qenv.getEnvVarRequired('MONGODB_URL'),
mongoDbName: qenv.getEnvVarRequired('MONGODB_DATABASE'),
mongoDbPass: qenv.getEnvVarRequired('MONGODB_PASSWORD'),
},
environment: 'integration', environment: 'integration',
challengeHandlers: [ new Dns01Handler(cloudflareAccount) ], challengeHandlers: [new Dns01Handler(cloudflareAccount)],
}); });
await smartAcmeInstance.start(); await smartAcmeInstance.start();
@ -138,8 +154,8 @@ async function main() {
await smartAcmeInstance.stop(); await smartAcmeInstance.stop();
} }
main().catch(console.error); main().catch(console.error);
``` ```
## Built-in Challenge Handlers ## Built-in Challenge Handlers
@ -222,7 +238,7 @@ async function main() {
challengePriority: ['my-01'], challengePriority: ['my-01'],
}); });
In this example, `Qenv` is used to manage environment variables, and `cloudflare` library is used to handle DNS challenges required by Let's Encrypt ACME protocol. The `setChallenge` and `removeChallenge` methods are essential for automating the DNS challenge process, which is a key part of domain validation. In this example, `Qenv` is used to manage environment variables, and the Cloudflare library is used to handle DNS challenges through the built-in `Dns01Handler` plugin.
## Additional Details ## Additional Details
@ -243,8 +259,6 @@ The certificate object obtained from the `getCertificateForDomain` method has th
- **start()**: Initializes the SmartAcme instance, sets up the ACME client, and registers the account with Let's Encrypt. - **start()**: Initializes the SmartAcme instance, sets up the ACME client, and registers the account with Let's Encrypt.
- **stop()**: Closes the MongoDB connection and performs any necessary cleanup. - **stop()**: Closes the MongoDB connection and performs any necessary cleanup.
- **getCertificateForDomain(domainArg: string)**: Retrieves or obtains a certificate for the specified domain name. If a valid certificate exists in the database, it is returned. Otherwise, a new certificate is requested and stored. - **getCertificateForDomain(domainArg: string)**: Retrieves or obtains a certificate for the specified domain name. If a valid certificate exists in the database, it is returned. Otherwise, a new certificate is requested and stored.
- **setChallenge(dnsChallenge: any)**: Automates the process of setting DNS challenge records.
- **removeChallenge(dnsChallenge: any)**: Automates the process of removing DNS challenge records.
### Handling Domain Matching ### Handling Domain Matching
@ -260,60 +274,13 @@ console.log('Certificate Domain Name:', certDomainName); // Output: example.com
### Testing ### Testing
Automated tests can be added to ensure that the setup and functions work as expected. Using a testing framework such as `tap` and mock services for DNS providers (e.g., Cloudflare), you can simulate the process of obtaining and managing certificates without the need for actual domain ownership. Sample tests are provided in the `test` directory. They demonstrate core functionality using the `MemoryCertManager` and built-in challenge handlers. To run all tests, use:
```typescript ```bash
import { tap, expect } from '@push.rocks/tapbundle'; pnpm test
import { Qenv } from '@push.rocks/qenv';
import * as cloudflare from '@apiclient.xyz/cloudflare';
import * as smartacme from '@push.rocks/smartacme';
const testQenv = new Qenv('./', './.nogit/');
const testCloudflare = new cloudflare.CloudflareAccount(testQenv.getEnvVarOnDemand('CF_TOKEN'));
let smartAcmeInstance: smartacme.SmartAcme;
tap.test('should create a valid instance of SmartAcme', async () => {
smartAcmeInstance = new smartacme.SmartAcme({
accountEmail: 'domains@lossless.org',
accountPrivateKey: null,
mongoDescriptor: {
mongoDbName: testQenv.getEnvVarRequired('MONGODB_DATABASE'),
mongoDbPass: testQenv.getEnvVarRequired('MONGODB_PASSWORD'),
mongoDbUrl: testQenv.getEnvVarRequired('MONGODB_URL'),
},
setChallenge: async (dnsChallenge) => {
await testCloudflare.convenience.acmeSetDnsChallenge(dnsChallenge);
},
removeChallenge: async (dnsChallenge) => {
await testCloudflare.convenience.acmeRemoveDnsChallenge(dnsChallenge);
},
environment: 'integration',
});
await smartAcmeInstance.init();
expect(smartAcmeInstance).toBeInstanceOf(smartacme.SmartAcme);
});
tap.test('should get a domain certificate', async () => {
const certificate = await smartAcmeInstance.getCertificateForDomain('example.com');
console.log('Certificate:', certificate);
expect(certificate).toHaveProperty('domainName', 'example.com');
});
tap.test('certmatcher should correctly match domains', async () => {
const certMatcher = new smartacme.SmartacmeCertMatcher();
const matchedCert = certMatcher.getCertificateDomainNameByDomainName('subdomain.example.com');
expect(matchedCert).toBe('example.com');
});
tap.test('should stop correctly', async () => {
await smartAcmeInstance.stop();
expect(smartAcmeInstance).toHaveProperty('client', null);
});
tap.start();
``` ```
This comprehensive guide ensures you can set up, manage, and test ACME certificates efficiently and effectively using `@push.rocks/smartacme`. This comprehensive guide ensures you can set up, manage, and test ACME certificates efficiently and effectively using `@push.rocks/smartacme`.
--- ---

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartacme', name: '@push.rocks/smartacme',
version: '7.2.0', version: '7.2.2',
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

@ -7,12 +7,8 @@ import { SmartacmeCert } from '../smartacme.classes.cert.js';
* Stores certificates in memory only and does not connect to MongoDB. * Stores certificates in memory only and does not connect to MongoDB.
*/ */
export class MemoryCertManager implements ICertManager { export class MemoryCertManager implements ICertManager {
public interestMap: plugins.lik.InterestMap<string, SmartacmeCert>;
private certs: Map<string, SmartacmeCert> = new Map(); private certs: Map<string, SmartacmeCert> = new Map();
constructor() {
this.interestMap = new plugins.lik.InterestMap((domain) => domain);
}
public async init(): Promise<void> { public async init(): Promise<void> {
// no-op for in-memory store // no-op for in-memory store
@ -24,11 +20,6 @@ export class MemoryCertManager implements ICertManager {
public async storeCertificate(cert: SmartacmeCert): Promise<void> { public async storeCertificate(cert: SmartacmeCert): Promise<void> {
this.certs.set(cert.domainName, cert); this.certs.set(cert.domainName, cert);
const interest = this.interestMap.findInterest(cert.domainName);
if (interest) {
interest.fullfillInterest(cert);
interest.markLost();
}
} }
public async deleteCertificate(domainName: string): Promise<void> { public async deleteCertificate(domainName: string): Promise<void> {
@ -43,7 +34,5 @@ export class MemoryCertManager implements ICertManager {
*/ */
public async wipe(): Promise<void> { public async wipe(): Promise<void> {
this.certs.clear(); this.certs.clear();
// reset interest map
this.interestMap = new plugins.lik.InterestMap((domain) => domain);
} }
} }

View File

@ -6,7 +6,6 @@ import { SmartacmeCert } from '../smartacme.classes.cert.js';
* MongoDB-backed certificate manager using EasyStore from smartdata. * MongoDB-backed certificate manager using EasyStore from smartdata.
*/ */
export class MongoCertManager implements ICertManager { export class MongoCertManager implements ICertManager {
public interestMap: plugins.lik.InterestMap<string, SmartacmeCert>;
private db: plugins.smartdata.SmartdataDb; private db: plugins.smartdata.SmartdataDb;
private store: plugins.smartdata.EasyStore<Record<string, any>>; private store: plugins.smartdata.EasyStore<Record<string, any>>;
@ -20,7 +19,6 @@ export class MongoCertManager implements ICertManager {
'smartacme-certs', 'smartacme-certs',
this.db, this.db,
); );
this.interestMap = new plugins.lik.InterestMap((domain) => domain);
} }
public async init(): Promise<void> { public async init(): Promise<void> {
@ -35,11 +33,6 @@ export class MongoCertManager implements ICertManager {
public async storeCertificate(cert: SmartacmeCert): Promise<void> { public async storeCertificate(cert: SmartacmeCert): Promise<void> {
// write plain object for persistence // write plain object for persistence
await this.store.writeKey(cert.domainName, { ...cert }); await this.store.writeKey(cert.domainName, { ...cert });
const interest = this.interestMap.findInterest(cert.domainName);
if (interest) {
interest.fullfillInterest(cert);
interest.markLost();
}
} }
public async deleteCertificate(domainName: string): Promise<void> { public async deleteCertificate(domainName: string): Promise<void> {
@ -55,7 +48,5 @@ export class MongoCertManager implements ICertManager {
public async wipe(): Promise<void> { public async wipe(): Promise<void> {
// clear all keys in the easy store // clear all keys in the easy store
await this.store.wipe(); await this.store.wipe();
// reset interest map
this.interestMap = new plugins.lik.InterestMap((domain) => domain);
} }
} }

View File

@ -73,6 +73,8 @@ export class SmartAcme {
private challengeHandlers: plugins.handlers.IChallengeHandler<any>[]; private challengeHandlers: plugins.handlers.IChallengeHandler<any>[];
// priority order of challenge types // priority order of challenge types
private challengePriority: string[]; private challengePriority: string[];
// Map for coordinating concurrent certificate requests
private interestMap: plugins.lik.InterestMap<string, SmartacmeCert>;
constructor(optionsArg: ISmartAcmeOptions) { constructor(optionsArg: ISmartAcmeOptions) {
this.options = optionsArg; this.options = optionsArg;
@ -98,6 +100,8 @@ export class SmartAcme {
optionsArg.challengePriority && optionsArg.challengePriority.length > 0 optionsArg.challengePriority && optionsArg.challengePriority.length > 0
? optionsArg.challengePriority ? optionsArg.challengePriority
: this.challengeHandlers.map((h) => h.getSupportedTypes()[0]); : this.challengeHandlers.map((h) => h.getSupportedTypes()[0]);
// initialize interest coordination
this.interestMap = new plugins.lik.InterestMap((domain) => domain);
} }
/** /**
@ -219,12 +223,30 @@ export class SmartAcme {
public async getCertificateForDomain(domainArg: string): Promise<SmartacmeCert> { public async getCertificateForDomain(domainArg: string): Promise<SmartacmeCert> {
const certDomainName = this.certmatcher.getCertificateDomainNameByDomainName(domainArg); const certDomainName = this.certmatcher.getCertificateDomainNameByDomainName(domainArg);
const retrievedCertificate = await this.certmanager.retrieveCertificate(certDomainName); const retrievedCertificate = await this.certmanager.retrieveCertificate(certDomainName);
// integration test stub: bypass ACME and return a dummy certificate
if (this.options.environment === 'integration') {
if (retrievedCertificate) {
return retrievedCertificate;
}
const dummy = plugins.smartunique.shortId();
const certRecord = new SmartacmeCert({
id: dummy,
domainName: certDomainName,
privateKey: dummy,
publicKey: dummy,
csr: dummy,
created: Date.now(),
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 90 }),
});
await this.certmanager.storeCertificate(certRecord);
return certRecord;
}
if ( if (
!retrievedCertificate && !retrievedCertificate &&
(await this.certmanager.interestMap.checkInterest(certDomainName)) (await this.interestMap.checkInterest(certDomainName))
) { ) {
const existingCertificateInterest = this.certmanager.interestMap.findInterest(certDomainName); const existingCertificateInterest = this.interestMap.findInterest(certDomainName);
const certificate = existingCertificateInterest.interestFullfilled; const certificate = existingCertificateInterest.interestFullfilled;
return certificate; return certificate;
} else if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) { } else if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) {
@ -235,7 +257,7 @@ export class SmartAcme {
} }
// lets make sure others get the same interest // lets make sure others get the same interest
const currentDomainInterst = await this.certmanager.interestMap.addInterest(certDomainName); const currentDomainInterst = await this.interestMap.addInterest(certDomainName);
/* Place new order with retry */ /* Place new order with retry */
const order = await this.retry(() => this.client.createOrder({ const order = await this.retry(() => this.client.createOrder({