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:
parent
48018b8955
commit
58015f0b58
@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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)
|
## 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
|
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
|
# @push.rocks/smartacme
|
||||||
|
|
||||||
A TypeScript-based ACME client with an easy yet powerful interface for LetsEncrypt certificate management.
|
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
|
```bash
|
||||||
npm install @push.rocks/smartacme --save
|
npm install @push.rocks/smartacme --save
|
||||||
```
|
```
|
||||||
````
|
|
||||||
|
|
||||||
or
|
or
|
||||||
|
|
||||||
@ -41,35 +39,40 @@ Ensure your project includes the necessary TypeScript configuration and dependen
|
|||||||
|
|
||||||
### Creating a SmartAcme Instance
|
### 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
|
```typescript
|
||||||
import { SmartAcme } from '@push.rocks/smartacme';
|
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({
|
const smartAcmeInstance = new SmartAcme({
|
||||||
accountEmail: 'youremail@example.com', // Email used for Let's Encrypt registration and recovery
|
accountEmail: 'youremail@example.com',
|
||||||
accountPrivateKey: null, // Private key for the account (optional, if not provided it will be generated)
|
|
||||||
mongoDescriptor: {
|
mongoDescriptor: {
|
||||||
mongoDbUrl: 'mongodb://yourmongoURL',
|
mongoDbUrl: 'mongodb://yourmongoURL',
|
||||||
mongoDbName: 'yourDbName',
|
mongoDbName: 'yourDbName',
|
||||||
mongoDbPass: 'yourDbPassword',
|
mongoDbPass: 'yourDbPassword',
|
||||||
},
|
},
|
||||||
removeChallenge: async (dnsChallenge) => {
|
environment: 'integration', // 'production' to request real certificates
|
||||||
// Implement logic here to remove DNS challenge records
|
retryOptions: {}, // optional retry/backoff settings
|
||||||
},
|
challengeHandlers: [
|
||||||
setChallenge: async (dnsChallenge) => {
|
new Dns01Handler(cfAccount),
|
||||||
// Implement logic here to create DNS challenge records
|
// you can add more handlers, e.g. Http01Handler
|
||||||
},
|
],
|
||||||
environment: 'integration', // Use 'production' for actual certificates
|
challengePriority: ['dns-01'], // optional ordering of challenge types
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Initializing SmartAcme
|
### Initializing SmartAcme
|
||||||
|
|
||||||
Before proceeding to request certificates, initialize your SmartAcme instance:
|
Before proceeding to request certificates, start your SmartAcme instance:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
await smartAcmeInstance.init();
|
await smartAcmeInstance.start();
|
||||||
```
|
```
|
||||||
|
|
||||||
### Obtaining a Certificate for a Domain
|
### Obtaining a Certificate for a Domain
|
||||||
@ -84,34 +87,7 @@ console.log('Certificate:', myCert);
|
|||||||
|
|
||||||
### Automating DNS Challenges
|
### 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.
|
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.
|
||||||
|
|
||||||
```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();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Managing Certificates
|
### Managing Certificates
|
||||||
|
|
||||||
@ -131,7 +107,7 @@ When creating an instance of `SmartAcme`, you can specify an `environment` optio
|
|||||||
|
|
||||||
### Complete Example
|
### 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
|
```typescript
|
||||||
import { SmartAcme } from '@push.rocks/smartacme';
|
import { SmartAcme } from '@push.rocks/smartacme';
|
||||||
@ -144,22 +120,16 @@ const cloudflareAccount = new cloudflare.CloudflareAccount(qenv.getEnvVarOnDeman
|
|||||||
async function main() {
|
async function main() {
|
||||||
const smartAcmeInstance = new SmartAcme({
|
const smartAcmeInstance = new SmartAcme({
|
||||||
accountEmail: 'youremail@example.com',
|
accountEmail: 'youremail@example.com',
|
||||||
accountPrivateKey: null,
|
|
||||||
mongoDescriptor: {
|
mongoDescriptor: {
|
||||||
mongoDbUrl: qenv.getEnvVarRequired('MONGODB_URL'),
|
mongoDbUrl: qenv.getEnvVarRequired('MONGODB_URL'),
|
||||||
mongoDbName: qenv.getEnvVarRequired('MONGODB_DATABASE'),
|
mongoDbName: qenv.getEnvVarRequired('MONGODB_DATABASE'),
|
||||||
mongoDbPass: qenv.getEnvVarRequired('MONGODB_PASSWORD'),
|
mongoDbPass: qenv.getEnvVarRequired('MONGODB_PASSWORD'),
|
||||||
},
|
},
|
||||||
setChallenge: async (dnsChallenge) => {
|
|
||||||
await cloudflareAccount.convenience.acmeSetDnsChallenge(dnsChallenge);
|
|
||||||
},
|
|
||||||
removeChallenge: async (dnsChallenge) => {
|
|
||||||
await cloudflareAccount.convenience.acmeRemoveDnsChallenge(dnsChallenge);
|
|
||||||
},
|
|
||||||
environment: 'integration',
|
environment: 'integration',
|
||||||
|
challengeHandlers: [ new Dns01Handler(cloudflareAccount) ],
|
||||||
});
|
});
|
||||||
|
|
||||||
await smartAcmeInstance.init();
|
await smartAcmeInstance.start();
|
||||||
|
|
||||||
const myDomain = 'example.com';
|
const myDomain = 'example.com';
|
||||||
const myCert = await smartAcmeInstance.getCertificateForDomain(myDomain);
|
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 = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartacme',
|
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.'
|
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;
|
accountPrivateKey?: string;
|
||||||
accountEmail: string;
|
accountEmail: string;
|
||||||
mongoDescriptor: plugins.smartdata.IMongoDescriptor;
|
mongoDescriptor: plugins.smartdata.IMongoDescriptor;
|
||||||
setChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise<any>;
|
// Removed legacy setChallenge/removeChallenge in favor of `challengeHandlers`
|
||||||
removeChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise<any>;
|
|
||||||
environment: 'production' | 'integration';
|
environment: 'production' | 'integration';
|
||||||
/**
|
/**
|
||||||
* Optional retry/backoff configuration for transient failures
|
* Optional retry/backoff configuration for transient failures
|
||||||
@ -27,6 +26,15 @@ export interface ISmartAcmeOptions {
|
|||||||
/** maximum delay cap in milliseconds */
|
/** maximum delay cap in milliseconds */
|
||||||
maxTimeoutMs?: number;
|
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
|
// the account private key
|
||||||
private privateKey: string;
|
private privateKey: string;
|
||||||
|
|
||||||
// challenge fullfillment
|
|
||||||
private setChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise<any>;
|
|
||||||
private removeChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise<any>;
|
|
||||||
|
|
||||||
// certmanager
|
// certmanager
|
||||||
private certmanager: SmartacmeCertManager;
|
private certmanager: SmartacmeCertManager;
|
||||||
@ -61,6 +66,10 @@ export class SmartAcme {
|
|||||||
private retryOptions: { retries: number; factor: number; minTimeoutMs: number; maxTimeoutMs: number };
|
private retryOptions: { retries: number; factor: number; minTimeoutMs: number; maxTimeoutMs: number };
|
||||||
// track pending DNS challenges for graceful shutdown
|
// track pending DNS challenges for graceful shutdown
|
||||||
private pendingChallenges: plugins.tsclass.network.IDnsChallenge[] = [];
|
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) {
|
constructor(optionsArg: ISmartAcmeOptions) {
|
||||||
this.options = optionsArg;
|
this.options = optionsArg;
|
||||||
@ -74,6 +83,18 @@ export class SmartAcme {
|
|||||||
minTimeoutMs: optionsArg.retryOptions?.minTimeoutMs ?? 1000,
|
minTimeoutMs: optionsArg.retryOptions?.minTimeoutMs ?? 1000,
|
||||||
maxTimeoutMs: optionsArg.retryOptions?.maxTimeoutMs ?? 30000,
|
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() {
|
public async start() {
|
||||||
this.privateKey =
|
this.privateKey =
|
||||||
this.options.accountPrivateKey || (await plugins.acme.forge.createPrivateKey()).toString();
|
this.options.accountPrivateKey || (await plugins.acme.forge.createPrivateKey()).toString();
|
||||||
this.setChallenge = this.options.setChallenge;
|
|
||||||
this.removeChallenge = this.options.removeChallenge;
|
|
||||||
|
|
||||||
// CertMangaer
|
// CertMangaer
|
||||||
this.certmanager = new SmartacmeCertManager(this, {
|
this.certmanager = new SmartacmeCertManager(this, {
|
||||||
@ -143,12 +162,22 @@ export class SmartAcme {
|
|||||||
}
|
}
|
||||||
/** Clean up pending challenges and shut down */
|
/** Clean up pending challenges and shut down */
|
||||||
private async handleShutdown(): Promise<void> {
|
private async handleShutdown(): Promise<void> {
|
||||||
for (const challenge of [...this.pendingChallenges]) {
|
for (const input of [...this.pendingChallenges]) {
|
||||||
try {
|
const type: string = (input as any).type;
|
||||||
await this.removeChallenge(challenge);
|
const handler = this.challengeHandlers.find((h) => h.getSupportedTypes().includes(type));
|
||||||
await this.logger.log('info', 'Removed pending challenge during shutdown', challenge);
|
if (handler) {
|
||||||
} catch (err) {
|
try {
|
||||||
await this.logger.log('error', 'Failed to remove pending challenge during shutdown', err);
|
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 = [];
|
this.pendingChallenges = [];
|
||||||
@ -211,41 +240,58 @@ export class SmartAcme {
|
|||||||
|
|
||||||
for (const authz of authorizations) {
|
for (const authz of authorizations) {
|
||||||
await this.logger.log('debug', 'Authorization received', authz);
|
await this.logger.log('debug', 'Authorization received', authz);
|
||||||
const fullHostName: string = `_acme-challenge.${authz.identifier.value}`;
|
// select a handler based on configured priority
|
||||||
const dnsChallenge = authz.challenges.find((challengeArg) => {
|
let selectedHandler: { type: string; handler: plugins.handlers.IChallengeHandler<any> } | null = null;
|
||||||
return challengeArg.type === 'dns-01';
|
let selectedChallengeArg: any = null;
|
||||||
});
|
for (const type of this.challengePriority) {
|
||||||
// process.exit(1);
|
const candidate = authz.challenges.find((c: any) => c.type === type);
|
||||||
const keyAuthorization: string = await this.client.getChallengeKeyAuthorization(dnsChallenge);
|
if (!candidate) continue;
|
||||||
// prepare DNS challenge record and track for cleanup
|
const handler = this.challengeHandlers.find((h) => h.getSupportedTypes().includes(type));
|
||||||
const challengeRecord: plugins.tsclass.network.IDnsChallenge = { hostName: fullHostName, challenge: keyAuthorization };
|
if (handler) {
|
||||||
this.pendingChallenges.push(challengeRecord);
|
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 {
|
try {
|
||||||
/* Satisfy challenge */
|
await this.retry(() => handler.prepare(input), `${type}.prepare`);
|
||||||
await this.retry(() => this.setChallenge(challengeRecord), 'setChallenge');
|
if (handler.verify) {
|
||||||
await plugins.smartdelay.delayFor(30000);
|
await this.retry(() => handler.verify!(input), `${type}.verify`);
|
||||||
await this.retry(() => this.smartdns.checkUntilAvailable(fullHostName, 'TXT', keyAuthorization, 100, 5000), 'dnsCheckUntilAvailable');
|
} else {
|
||||||
await this.logger.log('info', 'Cooling down extra 60 seconds for DNS regional propagation');
|
await this.retry(() => this.client.verifyChallenge(authz, selectedChallengeArg), `${type}.verifyChallenge`);
|
||||||
await plugins.smartdelay.delayFor(60000);
|
}
|
||||||
|
await this.retry(() => this.client.completeChallenge(selectedChallengeArg), `${type}.completeChallenge`);
|
||||||
/* Verify that challenge is satisfied */
|
await this.retry(() => this.client.waitForValidStatus(selectedChallengeArg), `${type}.waitForValidStatus`);
|
||||||
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');
|
|
||||||
} finally {
|
} finally {
|
||||||
/* Clean up challenge response */
|
|
||||||
try {
|
try {
|
||||||
await this.retry(() => this.removeChallenge(challengeRecord), 'removeChallenge');
|
await this.retry(() => handler.cleanup(input), `${type}.cleanup`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await this.logger.log('error', 'Error removing DNS challenge', err);
|
await this.logger.log('error', `Error during ${type}.cleanup`, err);
|
||||||
} finally {
|
} finally {
|
||||||
// remove from pending list
|
this.pendingChallenges = this.pendingChallenges.filter((c) => c !== input);
|
||||||
this.pendingChallenges = this.pendingChallenges.filter(c => c !== challengeRecord);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -37,3 +37,6 @@ export { tsclass };
|
|||||||
import * as acme from 'acme-client';
|
import * as acme from 'acme-client';
|
||||||
|
|
||||||
export { acme };
|
export { acme };
|
||||||
|
// local handlers for challenge types
|
||||||
|
import * as handlers from './handlers/index.js';
|
||||||
|
export { handlers };
|
||||||
|
@ -11,7 +11,12 @@
|
|||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {}
|
"paths": {}
|
||||||
},
|
},
|
||||||
|
"include": [
|
||||||
|
"ts/**/*.ts"
|
||||||
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"test",
|
||||||
"dist_*/**/*.d.ts"
|
"dist_*/**/*.d.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
Loading…
x
Reference in New Issue
Block a user