BREAKING CHANGE(SmartAcme): Refactor challenge handling by removing legacy setChallenge/removeChallenge in favor of pluggable challengeHandlers and update documentation and tests accordingly
This commit is contained in:
		@@ -1,5 +1,13 @@
 | 
			
		||||
# Changelog
 | 
			
		||||
 | 
			
		||||
## 2025-04-27 - 6.0.0 - BREAKING CHANGE(SmartAcme)
 | 
			
		||||
Refactor challenge handling by removing legacy setChallenge/removeChallenge in favor of pluggable challengeHandlers and update documentation and tests accordingly
 | 
			
		||||
 | 
			
		||||
- Removed legacy challenge methods and introduced new 'challengeHandlers' and 'challengePriority' options
 | 
			
		||||
- Updated readme examples to demonstrate usage with DNS-01 (and HTTP-01) handlers
 | 
			
		||||
- Refactored internal SmartAcme flow to select and process challenges via the new handler interface
 | 
			
		||||
- Adjusted tests (including integration tests) to align with the updated challenge handling mechanism
 | 
			
		||||
 | 
			
		||||
## 2025-04-27 - 5.1.0 - feat(smartacme)
 | 
			
		||||
Implement exponential backoff retry logic and graceful shutdown handling in SmartAcme; update acme-client dependency to v5.4.0
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										72
									
								
								readme.md
									
									
									
									
									
								
							
							
						
						
									
										72
									
								
								readme.md
									
									
									
									
									
								
							@@ -1,4 +1,3 @@
 | 
			
		||||
````markdown
 | 
			
		||||
# @push.rocks/smartacme
 | 
			
		||||
 | 
			
		||||
A TypeScript-based ACME client with an easy yet powerful interface for LetsEncrypt certificate management.
 | 
			
		||||
@@ -10,7 +9,6 @@ To install `@push.rocks/smartacme`, you can use npm or yarn. Run one of the foll
 | 
			
		||||
```bash
 | 
			
		||||
npm install @push.rocks/smartacme --save
 | 
			
		||||
```
 | 
			
		||||
````
 | 
			
		||||
 | 
			
		||||
or
 | 
			
		||||
 | 
			
		||||
@@ -41,35 +39,40 @@ Ensure your project includes the necessary TypeScript configuration and dependen
 | 
			
		||||
 | 
			
		||||
### Creating a SmartAcme Instance
 | 
			
		||||
 | 
			
		||||
Start by importing the `SmartAcme` class from the `@push.rocks/smartacme` package. You'll also need to import or define interfaces for your setup options:
 | 
			
		||||
Start by importing the `SmartAcme` class and any built-in handlers you plan to use. For example, to use DNS-01 via Cloudflare:
 | 
			
		||||
 | 
			
		||||
```typescript
 | 
			
		||||
import { SmartAcme } from '@push.rocks/smartacme';
 | 
			
		||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
 | 
			
		||||
import { Dns01Handler } from '@push.rocks/smartacme/ts/handlers/Dns01Handler.js';
 | 
			
		||||
 | 
			
		||||
// Create a Cloudflare account client with your API token
 | 
			
		||||
const cfAccount = new cloudflare.CloudflareAccount('YOUR_CF_TOKEN');
 | 
			
		||||
 | 
			
		||||
// Instantiate SmartAcme with one or more ACME challenge handlers
 | 
			
		||||
const smartAcmeInstance = new SmartAcme({
 | 
			
		||||
  accountEmail: 'youremail@example.com', // Email used for Let's Encrypt registration and recovery
 | 
			
		||||
  accountPrivateKey: null, // Private key for the account (optional, if not provided it will be generated)
 | 
			
		||||
  accountEmail: 'youremail@example.com',
 | 
			
		||||
  mongoDescriptor: {
 | 
			
		||||
    mongoDbUrl: 'mongodb://yourmongoURL',
 | 
			
		||||
    mongoDbName: 'yourDbName',
 | 
			
		||||
    mongoDbPass: 'yourDbPassword',
 | 
			
		||||
  },
 | 
			
		||||
  removeChallenge: async (dnsChallenge) => {
 | 
			
		||||
    // Implement logic here to remove DNS challenge records
 | 
			
		||||
  },
 | 
			
		||||
  setChallenge: async (dnsChallenge) => {
 | 
			
		||||
    // Implement logic here to create DNS challenge records
 | 
			
		||||
  },
 | 
			
		||||
  environment: 'integration', // Use 'production' for actual certificates
 | 
			
		||||
  environment: 'integration', // 'production' to request real certificates
 | 
			
		||||
  retryOptions: {},         // optional retry/backoff settings
 | 
			
		||||
  challengeHandlers: [
 | 
			
		||||
    new Dns01Handler(cfAccount),
 | 
			
		||||
    // you can add more handlers, e.g. Http01Handler
 | 
			
		||||
  ],
 | 
			
		||||
  challengePriority: ['dns-01'], // optional ordering of challenge types
 | 
			
		||||
});
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Initializing SmartAcme
 | 
			
		||||
 | 
			
		||||
Before proceeding to request certificates, initialize your SmartAcme instance:
 | 
			
		||||
Before proceeding to request certificates, start your SmartAcme instance:
 | 
			
		||||
 | 
			
		||||
```typescript
 | 
			
		||||
await smartAcmeInstance.init();
 | 
			
		||||
await smartAcmeInstance.start();
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Obtaining a Certificate for a Domain
 | 
			
		||||
@@ -84,34 +87,7 @@ console.log('Certificate:', myCert);
 | 
			
		||||
 | 
			
		||||
### Automating DNS Challenges
 | 
			
		||||
 | 
			
		||||
Part of the ACME protocol involves responding to DNS challenges issued by the certificate authority to prove control over a domain. Implement the `setChallenge` and `removeChallenge` functions in your SmartAcme configuration to automate this process. These functions receive a `dnsChallenge` argument containing details needed to create or remove the necessary DNS records.
 | 
			
		||||
 | 
			
		||||
```typescript
 | 
			
		||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
 | 
			
		||||
import { Qenv } from '@push.rocks/qenv';
 | 
			
		||||
 | 
			
		||||
const testQenv = new Qenv('./', './.nogit/');
 | 
			
		||||
const testCloudflare = new cloudflare.CloudflareAccount(testQenv.getEnvVarOnDemand('CF_TOKEN'));
 | 
			
		||||
 | 
			
		||||
const smartAcmeInstance = new SmartAcme({
 | 
			
		||||
  accountEmail: 'domains@example.com',
 | 
			
		||||
  accountPrivateKey: null,
 | 
			
		||||
  mongoDescriptor: {
 | 
			
		||||
    mongoDbName: testQenv.getEnvVarRequired('MONGODB_DATABASE'),
 | 
			
		||||
    mongoDbPass: testQenv.getEnvVarRequired('MONGODB_PASSWORD'),
 | 
			
		||||
    mongoDbUrl: testQenv.getEnvVarRequired('MONGODB_URL'),
 | 
			
		||||
  },
 | 
			
		||||
  removeChallenge: async (dnsChallenge) => {
 | 
			
		||||
    testCloudflare.convenience.acmeRemoveDnsChallenge(dnsChallenge);
 | 
			
		||||
  },
 | 
			
		||||
  setChallenge: async (dnsChallenge) => {
 | 
			
		||||
    testCloudflare.convenience.acmeSetDnsChallenge(dnsChallenge);
 | 
			
		||||
  },
 | 
			
		||||
  environment: 'integration',
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
await smartAcmeInstance.init();
 | 
			
		||||
```
 | 
			
		||||
SmartAcme uses pluggable ACME challenge handlers (see built-in handlers below) to automate domain validation. You configure handlers via the `challengeHandlers` array when creating the instance, and SmartAcme will invoke each handler’s `prepare`, optional `verify`, and `cleanup` methods during the ACME order flow.
 | 
			
		||||
 | 
			
		||||
### Managing Certificates
 | 
			
		||||
 | 
			
		||||
@@ -131,7 +107,7 @@ When creating an instance of `SmartAcme`, you can specify an `environment` optio
 | 
			
		||||
 | 
			
		||||
### Complete Example
 | 
			
		||||
 | 
			
		||||
Below is a complete example demonstrating how to use `@push.rocks/smartacme` to obtain and manage an ACME certificate with Let's Encrypt:
 | 
			
		||||
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
 | 
			
		||||
import { SmartAcme } from '@push.rocks/smartacme';
 | 
			
		||||
@@ -144,22 +120,16 @@ const cloudflareAccount = new cloudflare.CloudflareAccount(qenv.getEnvVarOnDeman
 | 
			
		||||
async function main() {
 | 
			
		||||
  const smartAcmeInstance = new SmartAcme({
 | 
			
		||||
    accountEmail: 'youremail@example.com',
 | 
			
		||||
    accountPrivateKey: null,
 | 
			
		||||
    mongoDescriptor: {
 | 
			
		||||
      mongoDbUrl: qenv.getEnvVarRequired('MONGODB_URL'),
 | 
			
		||||
      mongoDbName: qenv.getEnvVarRequired('MONGODB_DATABASE'),
 | 
			
		||||
      mongoDbPass: qenv.getEnvVarRequired('MONGODB_PASSWORD'),
 | 
			
		||||
    },
 | 
			
		||||
    setChallenge: async (dnsChallenge) => {
 | 
			
		||||
      await cloudflareAccount.convenience.acmeSetDnsChallenge(dnsChallenge);
 | 
			
		||||
    },
 | 
			
		||||
    removeChallenge: async (dnsChallenge) => {
 | 
			
		||||
      await cloudflareAccount.convenience.acmeRemoveDnsChallenge(dnsChallenge);
 | 
			
		||||
    },
 | 
			
		||||
    environment: 'integration',
 | 
			
		||||
    challengeHandlers: [ new Dns01Handler(cloudflareAccount) ],
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await smartAcmeInstance.init();
 | 
			
		||||
  await smartAcmeInstance.start();
 | 
			
		||||
 | 
			
		||||
  const myDomain = 'example.com';
 | 
			
		||||
  const myCert = await smartAcmeInstance.getCertificateForDomain(myDomain);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										21
									
								
								test/test.certmatcher.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								test/test.certmatcher.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
import { tap, expect } from '@push.rocks/tapbundle';
 | 
			
		||||
import { SmartacmeCertMatcher } from '../ts/smartacme.classes.certmatcher.js';
 | 
			
		||||
 | 
			
		||||
tap.test('should match 2-level domain', async () => {
 | 
			
		||||
  const matcher = new SmartacmeCertMatcher();
 | 
			
		||||
  expect(matcher.getCertificateDomainNameByDomainName('example.com')).toEqual('example.com');
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('should match 3-level domain', async () => {
 | 
			
		||||
  const matcher = new SmartacmeCertMatcher();
 | 
			
		||||
  expect(matcher.getCertificateDomainNameByDomainName('subdomain.example.com')).toEqual('example.com');
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('should return undefined for deeper domain', async () => {
 | 
			
		||||
  const matcher = new SmartacmeCertMatcher();
 | 
			
		||||
  // domain with 4 or more levels
 | 
			
		||||
  const result = matcher.getCertificateDomainNameByDomainName('a.b.example.com');
 | 
			
		||||
  expect(result).toEqual(undefined);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default tap.start();
 | 
			
		||||
							
								
								
									
										38
									
								
								test/test.handlers-dns01.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								test/test.handlers-dns01.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,38 @@
 | 
			
		||||
import { tap, expect } from '@push.rocks/tapbundle';
 | 
			
		||||
import { Dns01Handler } from '../ts/handlers/Dns01Handler.js';
 | 
			
		||||
 | 
			
		||||
tap.test('Dns01Handler prepare and cleanup calls Cloudflare and DNS functions', async () => {
 | 
			
		||||
  let setCalled = false;
 | 
			
		||||
  let removeCalled = false;
 | 
			
		||||
  // fake Cloudflare API
 | 
			
		||||
  const fakeCF: any = {
 | 
			
		||||
    convenience: {
 | 
			
		||||
      acmeSetDnsChallenge: async (ch: any) => {
 | 
			
		||||
        setCalled = true;
 | 
			
		||||
        expect(ch).toHaveProperty('hostName');
 | 
			
		||||
        expect(ch).toHaveProperty('challenge');
 | 
			
		||||
      },
 | 
			
		||||
      acmeRemoveDnsChallenge: async (ch: any) => {
 | 
			
		||||
        removeCalled = true;
 | 
			
		||||
        expect(ch).toHaveProperty('hostName');
 | 
			
		||||
        expect(ch).toHaveProperty('challenge');
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
  // fake DNS checker
 | 
			
		||||
  const fakeDNS: any = {
 | 
			
		||||
    checkUntilAvailable: async (host: string, rr: string, val: string, count: number, interval: number) => {
 | 
			
		||||
      expect(host).toEqual('test.host');
 | 
			
		||||
      expect(rr).toEqual('TXT');
 | 
			
		||||
      expect(val).toEqual('token');
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
  const handler = new Dns01Handler(fakeCF, fakeDNS);
 | 
			
		||||
  const input = { hostName: 'test.host', challenge: 'token' };
 | 
			
		||||
  await handler.prepare(input);
 | 
			
		||||
  expect(setCalled).toEqual(true);
 | 
			
		||||
  await handler.cleanup(input);
 | 
			
		||||
  expect(removeCalled).toEqual(true);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default tap.start();
 | 
			
		||||
							
								
								
									
										26
									
								
								test/test.handlers-http01.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								test/test.handlers-http01.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
import { tap, expect } from '@push.rocks/tapbundle';
 | 
			
		||||
import { Http01Handler } from '../ts/handlers/Http01Handler.js';
 | 
			
		||||
import { promises as fs } from 'fs';
 | 
			
		||||
import * as path from 'path';
 | 
			
		||||
import os from 'os';
 | 
			
		||||
 | 
			
		||||
tap.test('Http01Handler writes challenge file and removes it on cleanup', async () => {
 | 
			
		||||
  // create temporary webroot directory
 | 
			
		||||
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'http01-'));
 | 
			
		||||
  const handler = new Http01Handler({ webroot: tmpDir });
 | 
			
		||||
  const token = 'testtoken';
 | 
			
		||||
  const keyAuth = 'keyAuthValue';
 | 
			
		||||
  const webPath = `/.well-known/acme-challenge/${token}`;
 | 
			
		||||
  const input = { type: 'http-01', token, keyAuthorization: keyAuth, webPath };
 | 
			
		||||
  // prepare should write the file
 | 
			
		||||
  await handler.prepare(input);
 | 
			
		||||
  const filePath = path.join(tmpDir, webPath);
 | 
			
		||||
  const content = await fs.readFile(filePath, 'utf8');
 | 
			
		||||
  expect(content).toEqual(keyAuth);
 | 
			
		||||
  // cleanup should remove the file
 | 
			
		||||
  await handler.cleanup(input);
 | 
			
		||||
  const exists = await fs.stat(filePath).then(() => true).catch(() => false);
 | 
			
		||||
  expect(exists).toEqual(false);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default tap.start();
 | 
			
		||||
							
								
								
									
										47
									
								
								test/test.smartacme.integration.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								test/test.smartacme.integration.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,47 @@
 | 
			
		||||
import { tap, expect } from '@push.rocks/tapbundle';
 | 
			
		||||
import { Qenv } from '@push.rocks/qenv';
 | 
			
		||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
 | 
			
		||||
import { SmartAcme } from '../ts/index.js';
 | 
			
		||||
import { Dns01Handler } from '../ts/handlers/Dns01Handler.js';
 | 
			
		||||
 | 
			
		||||
// Load environment variables for credentials (stored under .nogit/)
 | 
			
		||||
const testQenv = new Qenv('./', './.nogit/');
 | 
			
		||||
// Cloudflare API token for DNS-01 challenge (must be set in .nogit/ or env)
 | 
			
		||||
const cfToken = (await testQenv.getEnvVarOnDemand('CF_TOKEN'))!;
 | 
			
		||||
const cfAccount = new cloudflare.CloudflareAccount(cfToken);
 | 
			
		||||
// MongoDB connection settings for certificate storage (must be set in .nogit/ or env)
 | 
			
		||||
const mongoDbName = (await testQenv.getEnvVarOnDemand('MONGODB_DATABASE'))!;
 | 
			
		||||
const mongoDbPass = (await testQenv.getEnvVarOnDemand('MONGODB_PASSWORD'))!;
 | 
			
		||||
const mongoDbUrl = (await testQenv.getEnvVarOnDemand('MONGODB_URL'))!;
 | 
			
		||||
 | 
			
		||||
let smartAcmeInstance: SmartAcme;
 | 
			
		||||
 | 
			
		||||
tap.test('create SmartAcme instance with DNS-01 handler and start', async () => {
 | 
			
		||||
  smartAcmeInstance = new SmartAcme({
 | 
			
		||||
    accountEmail: 'domains@lossless.org',
 | 
			
		||||
    mongoDescriptor: { mongoDbName, mongoDbPass, mongoDbUrl },
 | 
			
		||||
    environment: 'integration',
 | 
			
		||||
    retryOptions: {},
 | 
			
		||||
    challengeHandlers: [new Dns01Handler(cfAccount)],
 | 
			
		||||
    challengePriority: ['dns-01'],
 | 
			
		||||
  });
 | 
			
		||||
  await smartAcmeInstance.start();
 | 
			
		||||
  expect(smartAcmeInstance).toBeInstanceOf(SmartAcme);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('get a domain certificate via DNS-01 challenge', async () => {
 | 
			
		||||
  // Replace 'bleu.de' with your test domain if different
 | 
			
		||||
  const domain = 'bleu.de';
 | 
			
		||||
  const cert = await smartAcmeInstance.getCertificateForDomain(domain);
 | 
			
		||||
  expect(cert).toHaveProperty('domainName');
 | 
			
		||||
  expect(cert.domainName).toEqual(domain);
 | 
			
		||||
  expect(cert).toHaveProperty('publicKey');
 | 
			
		||||
  expect(typeof cert.publicKey).toEqual('string');
 | 
			
		||||
  expect(cert.publicKey.length).toBeGreaterThan(0);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('stop SmartAcme instance', async () => {
 | 
			
		||||
  await smartAcmeInstance.stop();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default tap.start();
 | 
			
		||||
							
								
								
									
										32
									
								
								test/test.smartacme.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								test/test.smartacme.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
			
		||||
import { tap, expect } from '@push.rocks/tapbundle';
 | 
			
		||||
import { SmartAcme } from '../ts/index.js';
 | 
			
		||||
import type { IChallengeHandler } from '../ts/handlers/IChallengeHandler.js';
 | 
			
		||||
 | 
			
		||||
// Dummy handler for testing
 | 
			
		||||
class DummyHandler implements IChallengeHandler<any> {
 | 
			
		||||
  getSupportedTypes(): string[] { return ['dns-01']; }
 | 
			
		||||
  async prepare(_: any): Promise<void> { /* no-op */ }
 | 
			
		||||
  async cleanup(_: any): Promise<void> { /* no-op */ }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
tap.test('constructor throws without challengeHandlers', async () => {
 | 
			
		||||
  expect(() => new SmartAcme({
 | 
			
		||||
    accountEmail: 'test@example.com',
 | 
			
		||||
    mongoDescriptor: { mongoDbName: 'db', mongoDbPass: 'pass', mongoDbUrl: 'url' },
 | 
			
		||||
    environment: 'integration',
 | 
			
		||||
    retryOptions: {},
 | 
			
		||||
  } as any)).toThrow();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('constructor accepts valid challengeHandlers', async () => {
 | 
			
		||||
  const sa = new SmartAcme({
 | 
			
		||||
    accountEmail: 'test@example.com',
 | 
			
		||||
    mongoDescriptor: { mongoDbName: 'db', mongoDbPass: 'pass', mongoDbUrl: 'url' },
 | 
			
		||||
    environment: 'integration',
 | 
			
		||||
    retryOptions: {},
 | 
			
		||||
    challengeHandlers: [new DummyHandler()],
 | 
			
		||||
  });
 | 
			
		||||
  expect(sa).toBeInstanceOf(SmartAcme);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default tap.start();
 | 
			
		||||
							
								
								
									
										48
									
								
								test/test.ts
									
									
									
									
									
								
							
							
						
						
									
										48
									
								
								test/test.ts
									
									
									
									
									
								
							@@ -1,48 +0,0 @@
 | 
			
		||||
import { tap, expect } from '@push.rocks/tapbundle';
 | 
			
		||||
import { Qenv } from '@push.rocks/qenv';
 | 
			
		||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
 | 
			
		||||
 | 
			
		||||
const testQenv = new Qenv('./', './.nogit/');
 | 
			
		||||
const testCloudflare = new cloudflare.CloudflareAccount(await testQenv.getEnvVarOnDemand('CF_TOKEN'));
 | 
			
		||||
 | 
			
		||||
import * as smartacme from '../ts/index.js';
 | 
			
		||||
 | 
			
		||||
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: await testQenv.getEnvVarOnDemand('MONGODB_DATABASE'),
 | 
			
		||||
      mongoDbPass: await testQenv.getEnvVarOnDemand('MONGODB_PASSWORD'),
 | 
			
		||||
      mongoDbUrl: await testQenv.getEnvVarOnDemand('MONGODB_URL'),
 | 
			
		||||
    },
 | 
			
		||||
    removeChallenge: async (dnsChallenge) => {
 | 
			
		||||
      testCloudflare.convenience.acmeRemoveDnsChallenge(dnsChallenge);
 | 
			
		||||
    },
 | 
			
		||||
    setChallenge: async (dnsChallenge) => {
 | 
			
		||||
      testCloudflare.convenience.acmeSetDnsChallenge(dnsChallenge);
 | 
			
		||||
    },
 | 
			
		||||
    environment: 'integration',
 | 
			
		||||
  });
 | 
			
		||||
  await smartAcmeInstance.start();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('should get a domain certificate', async () => {
 | 
			
		||||
  const certificate = await smartAcmeInstance.getCertificateForDomain('bleu.de');
 | 
			
		||||
  console.log(certificate);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('certmatcher should correctly match domains', async () => {
 | 
			
		||||
  const certMatcherMod = await import('../ts/smartacme.classes.certmatcher.js');
 | 
			
		||||
  const certMatcher = new certMatcherMod.SmartacmeCertMatcher();
 | 
			
		||||
  const matchedCert = certMatcher.getCertificateDomainNameByDomainName('level3.level2.level1');
 | 
			
		||||
  expect(matchedCert).toEqual('level2.level1');
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('should stop correctly', async () => {
 | 
			
		||||
  await smartAcmeInstance.stop();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.start();
 | 
			
		||||
@@ -3,6 +3,6 @@
 | 
			
		||||
 */
 | 
			
		||||
export const commitinfo = {
 | 
			
		||||
  name: '@push.rocks/smartacme',
 | 
			
		||||
  version: '5.1.0',
 | 
			
		||||
  version: '6.0.0',
 | 
			
		||||
  description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.'
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										40
									
								
								ts/handlers/Dns01Handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								ts/handlers/Dns01Handler.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,40 @@
 | 
			
		||||
import * as plugins from '../smartacme.plugins.js';
 | 
			
		||||
import type { IChallengeHandler } from './IChallengeHandler.js';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * DNS-01 challenge handler using CloudflareAccount and Smartdns.
 | 
			
		||||
 */
 | 
			
		||||
export class Dns01Handler implements IChallengeHandler<plugins.tsclass.network.IDnsChallenge> {
 | 
			
		||||
  private cf: any;
 | 
			
		||||
  private smartdns: plugins.smartdnsClient.Smartdns;
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    cloudflareAccount: any,
 | 
			
		||||
    smartdnsInstance?: plugins.smartdnsClient.Smartdns,
 | 
			
		||||
  ) {
 | 
			
		||||
    this.cf = cloudflareAccount;
 | 
			
		||||
    this.smartdns = smartdnsInstance ?? new plugins.smartdnsClient.Smartdns({});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public getSupportedTypes(): string[] {
 | 
			
		||||
    return ['dns-01'];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async prepare(ch: plugins.tsclass.network.IDnsChallenge): Promise<void> {
 | 
			
		||||
    // set DNS TXT record
 | 
			
		||||
    await this.cf.convenience.acmeSetDnsChallenge(ch);
 | 
			
		||||
    // wait for DNS propagation
 | 
			
		||||
    await this.smartdns.checkUntilAvailable(
 | 
			
		||||
      ch.hostName,
 | 
			
		||||
      'TXT',
 | 
			
		||||
      ch.challenge,
 | 
			
		||||
      100,
 | 
			
		||||
      5000,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async cleanup(ch: plugins.tsclass.network.IDnsChallenge): Promise<void> {
 | 
			
		||||
    // remove DNS TXT record
 | 
			
		||||
    await this.cf.convenience.acmeRemoveDnsChallenge(ch);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										54
									
								
								ts/handlers/Http01Handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								ts/handlers/Http01Handler.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,54 @@
 | 
			
		||||
import { promises as fs } from 'fs';
 | 
			
		||||
import * as path from 'path';
 | 
			
		||||
import type { IChallengeHandler } from './IChallengeHandler.js';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * HTTP-01 ACME challenge handler using file-system webroot.
 | 
			
		||||
 * Writes and removes the challenge file under <webroot>/.well-known/acme-challenge/. 
 | 
			
		||||
 */
 | 
			
		||||
export interface Http01HandlerOptions {
 | 
			
		||||
  /**
 | 
			
		||||
   * Directory that serves HTTP requests for /.well-known/acme-challenge
 | 
			
		||||
   */
 | 
			
		||||
  webroot: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class Http01Handler implements IChallengeHandler<{
 | 
			
		||||
  type: string;
 | 
			
		||||
  token: string;
 | 
			
		||||
  keyAuthorization: string;
 | 
			
		||||
  webPath: string;
 | 
			
		||||
}> {
 | 
			
		||||
  private webroot: string;
 | 
			
		||||
 | 
			
		||||
  constructor(options: Http01HandlerOptions) {
 | 
			
		||||
    this.webroot = options.webroot;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public getSupportedTypes(): string[] {
 | 
			
		||||
    return ['http-01'];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async prepare(ch: { token: string; keyAuthorization: string; webPath: string }): Promise<void> {
 | 
			
		||||
    const relWebPath = ch.webPath.replace(/^\/+/, '');
 | 
			
		||||
    const filePath = path.join(this.webroot, relWebPath);
 | 
			
		||||
    const dir = path.dirname(filePath);
 | 
			
		||||
    await fs.mkdir(dir, { recursive: true });
 | 
			
		||||
    await fs.writeFile(filePath, ch.keyAuthorization, 'utf8');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async verify(ch: { webPath: string; keyAuthorization: string }): Promise<void> {
 | 
			
		||||
    // Optional: implement HTTP polling if desired
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async cleanup(ch: { token: string; webPath: string }): Promise<void> {
 | 
			
		||||
    const relWebPath = ch.webPath.replace(/^\/+/, '');
 | 
			
		||||
    const filePath = path.join(this.webroot, relWebPath);
 | 
			
		||||
    try {
 | 
			
		||||
      await fs.unlink(filePath);
 | 
			
		||||
    } catch {
 | 
			
		||||
      // ignore missing file
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										22
									
								
								ts/handlers/IChallengeHandler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								ts/handlers/IChallengeHandler.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
/**
 | 
			
		||||
 * Pluggable interface for ACME challenge handlers.
 | 
			
		||||
 * Supports DNS-01, HTTP-01, TLS-ALPN-01, or custom challenge types.
 | 
			
		||||
 */
 | 
			
		||||
export interface IChallengeHandler<T> {
 | 
			
		||||
  /**
 | 
			
		||||
   * ACME challenge types this handler supports (e.g. ['dns-01']).
 | 
			
		||||
   */
 | 
			
		||||
  getSupportedTypes(): string[];
 | 
			
		||||
  /**
 | 
			
		||||
   * Prepare the challenge: set DNS record, start HTTP/TLS server, etc.
 | 
			
		||||
   */
 | 
			
		||||
  prepare(ch: T): Promise<void>;
 | 
			
		||||
  /**
 | 
			
		||||
   * Optional extra verify step (HTTP GET, ALPN handshake).
 | 
			
		||||
   */
 | 
			
		||||
  verify?(ch: T): Promise<void>;
 | 
			
		||||
  /**
 | 
			
		||||
   * Clean up resources: remove DNS record, stop server.
 | 
			
		||||
   */
 | 
			
		||||
  cleanup(ch: T): Promise<void>;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										4
									
								
								ts/handlers/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								ts/handlers/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
export type { IChallengeHandler } from './IChallengeHandler.js';
 | 
			
		||||
// Removed legacy handler adapter
 | 
			
		||||
export { Dns01Handler } from './Dns01Handler.js';
 | 
			
		||||
export { Http01Handler } from './Http01Handler.js';
 | 
			
		||||
@@ -11,8 +11,7 @@ export interface ISmartAcmeOptions {
 | 
			
		||||
  accountPrivateKey?: string;
 | 
			
		||||
  accountEmail: string;
 | 
			
		||||
  mongoDescriptor: plugins.smartdata.IMongoDescriptor;
 | 
			
		||||
  setChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise<any>;
 | 
			
		||||
  removeChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise<any>;
 | 
			
		||||
  // Removed legacy setChallenge/removeChallenge in favor of `challengeHandlers`
 | 
			
		||||
  environment: 'production' | 'integration';
 | 
			
		||||
  /**
 | 
			
		||||
   * Optional retry/backoff configuration for transient failures
 | 
			
		||||
@@ -27,6 +26,15 @@ export interface ISmartAcmeOptions {
 | 
			
		||||
    /** maximum delay cap in milliseconds */
 | 
			
		||||
    maxTimeoutMs?: number;
 | 
			
		||||
  };
 | 
			
		||||
  /**
 | 
			
		||||
   * Pluggable ACME challenge handlers (DNS-01, HTTP-01, TLS-ALPN-01, etc.)
 | 
			
		||||
   */
 | 
			
		||||
  challengeHandlers?: plugins.handlers.IChallengeHandler<any>[];
 | 
			
		||||
  /**
 | 
			
		||||
   * Order of challenge types to try (e.g. ['http-01','dns-01']).
 | 
			
		||||
   * Defaults to ['dns-01'] or first supported type from handlers.
 | 
			
		||||
   */
 | 
			
		||||
  challengePriority?: string[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@@ -50,9 +58,6 @@ export class SmartAcme {
 | 
			
		||||
  // the account private key
 | 
			
		||||
  private privateKey: string;
 | 
			
		||||
 | 
			
		||||
  // challenge fullfillment
 | 
			
		||||
  private setChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise<any>;
 | 
			
		||||
  private removeChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise<any>;
 | 
			
		||||
 | 
			
		||||
  // certmanager
 | 
			
		||||
  private certmanager: SmartacmeCertManager;
 | 
			
		||||
@@ -61,6 +66,10 @@ export class SmartAcme {
 | 
			
		||||
  private retryOptions: { retries: number; factor: number; minTimeoutMs: number; maxTimeoutMs: number };
 | 
			
		||||
  // track pending DNS challenges for graceful shutdown
 | 
			
		||||
  private pendingChallenges: plugins.tsclass.network.IDnsChallenge[] = [];
 | 
			
		||||
  // configured pluggable ACME challenge handlers
 | 
			
		||||
  private challengeHandlers: plugins.handlers.IChallengeHandler<any>[];
 | 
			
		||||
  // priority order of challenge types
 | 
			
		||||
  private challengePriority: string[];
 | 
			
		||||
 | 
			
		||||
  constructor(optionsArg: ISmartAcmeOptions) {
 | 
			
		||||
    this.options = optionsArg;
 | 
			
		||||
@@ -74,6 +83,18 @@ export class SmartAcme {
 | 
			
		||||
      minTimeoutMs: optionsArg.retryOptions?.minTimeoutMs ?? 1000,
 | 
			
		||||
      maxTimeoutMs: optionsArg.retryOptions?.maxTimeoutMs ?? 30000,
 | 
			
		||||
    };
 | 
			
		||||
    // initialize challenge handlers (must provide at least one)
 | 
			
		||||
    if (!optionsArg.challengeHandlers || optionsArg.challengeHandlers.length === 0) {
 | 
			
		||||
      throw new Error(
 | 
			
		||||
        'You must provide at least one ACME challenge handler via options.challengeHandlers',
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    this.challengeHandlers = optionsArg.challengeHandlers;
 | 
			
		||||
    // initialize challenge priority
 | 
			
		||||
    this.challengePriority =
 | 
			
		||||
      optionsArg.challengePriority && optionsArg.challengePriority.length > 0
 | 
			
		||||
        ? optionsArg.challengePriority
 | 
			
		||||
        : this.challengeHandlers.map((h) => h.getSupportedTypes()[0]);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
@@ -85,8 +106,6 @@ export class SmartAcme {
 | 
			
		||||
  public async start() {
 | 
			
		||||
    this.privateKey =
 | 
			
		||||
      this.options.accountPrivateKey || (await plugins.acme.forge.createPrivateKey()).toString();
 | 
			
		||||
    this.setChallenge = this.options.setChallenge;
 | 
			
		||||
    this.removeChallenge = this.options.removeChallenge;
 | 
			
		||||
 | 
			
		||||
    // CertMangaer
 | 
			
		||||
    this.certmanager = new SmartacmeCertManager(this, {
 | 
			
		||||
@@ -143,12 +162,22 @@ export class SmartAcme {
 | 
			
		||||
    }
 | 
			
		||||
    /** Clean up pending challenges and shut down */
 | 
			
		||||
    private async handleShutdown(): Promise<void> {
 | 
			
		||||
      for (const challenge of [...this.pendingChallenges]) {
 | 
			
		||||
        try {
 | 
			
		||||
          await this.removeChallenge(challenge);
 | 
			
		||||
          await this.logger.log('info', 'Removed pending challenge during shutdown', challenge);
 | 
			
		||||
        } catch (err) {
 | 
			
		||||
          await this.logger.log('error', 'Failed to remove pending challenge during shutdown', err);
 | 
			
		||||
      for (const input of [...this.pendingChallenges]) {
 | 
			
		||||
        const type: string = (input as any).type;
 | 
			
		||||
        const handler = this.challengeHandlers.find((h) => h.getSupportedTypes().includes(type));
 | 
			
		||||
        if (handler) {
 | 
			
		||||
          try {
 | 
			
		||||
            await handler.cleanup(input);
 | 
			
		||||
            await this.logger.log('info', `Removed pending ${type} challenge during shutdown`, input);
 | 
			
		||||
          } catch (err) {
 | 
			
		||||
            await this.logger.log('error', `Failed to remove pending ${type} challenge during shutdown`, err);
 | 
			
		||||
          }
 | 
			
		||||
        } else {
 | 
			
		||||
          await this.logger.log(
 | 
			
		||||
            'warn',
 | 
			
		||||
            `No handler for pending challenge type '${type}' during shutdown; skipping cleanup`,
 | 
			
		||||
            input,
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      this.pendingChallenges = [];
 | 
			
		||||
@@ -211,41 +240,58 @@ export class SmartAcme {
 | 
			
		||||
 | 
			
		||||
    for (const authz of authorizations) {
 | 
			
		||||
      await this.logger.log('debug', 'Authorization received', authz);
 | 
			
		||||
      const fullHostName: string = `_acme-challenge.${authz.identifier.value}`;
 | 
			
		||||
      const dnsChallenge = authz.challenges.find((challengeArg) => {
 | 
			
		||||
        return challengeArg.type === 'dns-01';
 | 
			
		||||
      });
 | 
			
		||||
      // process.exit(1);
 | 
			
		||||
      const keyAuthorization: string = await this.client.getChallengeKeyAuthorization(dnsChallenge);
 | 
			
		||||
      // prepare DNS challenge record and track for cleanup
 | 
			
		||||
      const challengeRecord: plugins.tsclass.network.IDnsChallenge = { hostName: fullHostName, challenge: keyAuthorization };
 | 
			
		||||
      this.pendingChallenges.push(challengeRecord);
 | 
			
		||||
 | 
			
		||||
      // select a handler based on configured priority
 | 
			
		||||
      let selectedHandler: { type: string; handler: plugins.handlers.IChallengeHandler<any> } | null = null;
 | 
			
		||||
      let selectedChallengeArg: any = null;
 | 
			
		||||
      for (const type of this.challengePriority) {
 | 
			
		||||
        const candidate = authz.challenges.find((c: any) => c.type === type);
 | 
			
		||||
        if (!candidate) continue;
 | 
			
		||||
        const handler = this.challengeHandlers.find((h) => h.getSupportedTypes().includes(type));
 | 
			
		||||
        if (handler) {
 | 
			
		||||
          selectedHandler = { type, handler };
 | 
			
		||||
          selectedChallengeArg = candidate;
 | 
			
		||||
          break;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      if (!selectedHandler) {
 | 
			
		||||
        throw new Error(`No challenge handler for domain ${authz.identifier.value}: supported types [${this.challengePriority.join(',')}]`);
 | 
			
		||||
      }
 | 
			
		||||
      const { type, handler } = selectedHandler;
 | 
			
		||||
      // build handler input with keyAuthorization
 | 
			
		||||
      let input: any;
 | 
			
		||||
      // retrieve keyAuthorization for challenge
 | 
			
		||||
      const keyAuth = await this.client.getChallengeKeyAuthorization(selectedChallengeArg);
 | 
			
		||||
      if (type === 'dns-01') {
 | 
			
		||||
        input = { type, hostName: `_acme-challenge.${authz.identifier.value}`, challenge: keyAuth };
 | 
			
		||||
      } else if (type === 'http-01') {
 | 
			
		||||
        // HTTP-01 requires serving token at webPath
 | 
			
		||||
        input = {
 | 
			
		||||
          type,
 | 
			
		||||
          token: (selectedChallengeArg as any).token,
 | 
			
		||||
          keyAuthorization: keyAuth,
 | 
			
		||||
          webPath: `/.well-known/acme-challenge/${(selectedChallengeArg as any).token}`,
 | 
			
		||||
        };
 | 
			
		||||
      } else {
 | 
			
		||||
        // generic challenge input: include raw challenge properties
 | 
			
		||||
        input = { type, keyAuthorization: keyAuth, ...selectedChallengeArg };
 | 
			
		||||
      }
 | 
			
		||||
      this.pendingChallenges.push(input);
 | 
			
		||||
      try {
 | 
			
		||||
        /* Satisfy challenge */
 | 
			
		||||
        await this.retry(() => this.setChallenge(challengeRecord), 'setChallenge');
 | 
			
		||||
        await plugins.smartdelay.delayFor(30000);
 | 
			
		||||
        await this.retry(() => this.smartdns.checkUntilAvailable(fullHostName, 'TXT', keyAuthorization, 100, 5000), 'dnsCheckUntilAvailable');
 | 
			
		||||
        await this.logger.log('info', 'Cooling down extra 60 seconds for DNS regional propagation');
 | 
			
		||||
        await plugins.smartdelay.delayFor(60000);
 | 
			
		||||
 | 
			
		||||
        /* Verify that challenge is satisfied */
 | 
			
		||||
        await this.retry(() => this.client.verifyChallenge(authz, dnsChallenge), 'verifyChallenge');
 | 
			
		||||
 | 
			
		||||
        /* Notify ACME provider that challenge is satisfied */
 | 
			
		||||
        await this.retry(() => this.client.completeChallenge(dnsChallenge), 'completeChallenge');
 | 
			
		||||
 | 
			
		||||
        /* Wait for ACME provider to respond with valid status */
 | 
			
		||||
        await this.retry(() => this.client.waitForValidStatus(dnsChallenge), 'waitForValidStatus');
 | 
			
		||||
        await this.retry(() => handler.prepare(input), `${type}.prepare`);
 | 
			
		||||
        if (handler.verify) {
 | 
			
		||||
          await this.retry(() => handler.verify!(input), `${type}.verify`);
 | 
			
		||||
        } else {
 | 
			
		||||
          await this.retry(() => this.client.verifyChallenge(authz, selectedChallengeArg), `${type}.verifyChallenge`);
 | 
			
		||||
        }
 | 
			
		||||
        await this.retry(() => this.client.completeChallenge(selectedChallengeArg), `${type}.completeChallenge`);
 | 
			
		||||
        await this.retry(() => this.client.waitForValidStatus(selectedChallengeArg), `${type}.waitForValidStatus`);
 | 
			
		||||
      } finally {
 | 
			
		||||
        /* Clean up challenge response */
 | 
			
		||||
        try {
 | 
			
		||||
          await this.retry(() => this.removeChallenge(challengeRecord), 'removeChallenge');
 | 
			
		||||
          await this.retry(() => handler.cleanup(input), `${type}.cleanup`);
 | 
			
		||||
        } catch (err) {
 | 
			
		||||
          await this.logger.log('error', 'Error removing DNS challenge', err);
 | 
			
		||||
          await this.logger.log('error', `Error during ${type}.cleanup`, err);
 | 
			
		||||
        } finally {
 | 
			
		||||
          // remove from pending list
 | 
			
		||||
          this.pendingChallenges = this.pendingChallenges.filter(c => c !== challengeRecord);
 | 
			
		||||
          this.pendingChallenges = this.pendingChallenges.filter((c) => c !== input);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -37,3 +37,6 @@ export { tsclass };
 | 
			
		||||
import * as acme from 'acme-client';
 | 
			
		||||
 | 
			
		||||
export { acme };
 | 
			
		||||
// local handlers for challenge types
 | 
			
		||||
import * as handlers from './handlers/index.js';
 | 
			
		||||
export { handlers };
 | 
			
		||||
 
 | 
			
		||||
@@ -11,7 +11,12 @@
 | 
			
		||||
    "baseUrl": ".",
 | 
			
		||||
    "paths": {}
 | 
			
		||||
  },
 | 
			
		||||
  "include": [
 | 
			
		||||
    "ts/**/*.ts"
 | 
			
		||||
  ],
 | 
			
		||||
  "exclude": [
 | 
			
		||||
    "node_modules",
 | 
			
		||||
    "test",
 | 
			
		||||
    "dist_*/**/*.d.ts"
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user