From 58015f0b58a754bf13c467798d31281abf6a65e7 Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Sun, 27 Apr 2025 14:28:05 +0000 Subject: [PATCH] BREAKING CHANGE(SmartAcme): Refactor challenge handling by removing legacy setChallenge/removeChallenge in favor of pluggable challengeHandlers and update documentation and tests accordingly --- changelog.md | 8 ++ readme.md | 72 +++++----------- test/test.certmatcher.ts | 21 +++++ test/test.handlers-dns01.ts | 38 +++++++++ test/test.handlers-http01.ts | 26 ++++++ test/test.smartacme.integration.ts | 47 ++++++++++ test/test.smartacme.ts | 32 +++++++ test/test.ts | 48 ----------- ts/00_commitinfo_data.ts | 2 +- ts/handlers/Dns01Handler.ts | 40 +++++++++ ts/handlers/Http01Handler.ts | 54 ++++++++++++ ts/handlers/IChallengeHandler.ts | 22 +++++ ts/handlers/index.ts | 4 + ts/smartacme.classes.smartacme.ts | 132 +++++++++++++++++++---------- ts/smartacme.plugins.ts | 3 + tsconfig.json | 5 ++ 16 files changed, 411 insertions(+), 143 deletions(-) create mode 100644 test/test.certmatcher.ts create mode 100644 test/test.handlers-dns01.ts create mode 100644 test/test.handlers-http01.ts create mode 100644 test/test.smartacme.integration.ts create mode 100644 test/test.smartacme.ts delete mode 100644 test/test.ts create mode 100644 ts/handlers/Dns01Handler.ts create mode 100644 ts/handlers/Http01Handler.ts create mode 100644 ts/handlers/IChallengeHandler.ts create mode 100644 ts/handlers/index.ts diff --git a/changelog.md b/changelog.md index 9446c39..9685911 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/readme.md b/readme.md index 9d3daf2..98c2551 100644 --- a/readme.md +++ b/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); diff --git a/test/test.certmatcher.ts b/test/test.certmatcher.ts new file mode 100644 index 0000000..c4a6843 --- /dev/null +++ b/test/test.certmatcher.ts @@ -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(); \ No newline at end of file diff --git a/test/test.handlers-dns01.ts b/test/test.handlers-dns01.ts new file mode 100644 index 0000000..13f6805 --- /dev/null +++ b/test/test.handlers-dns01.ts @@ -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(); \ No newline at end of file diff --git a/test/test.handlers-http01.ts b/test/test.handlers-http01.ts new file mode 100644 index 0000000..997c63b --- /dev/null +++ b/test/test.handlers-http01.ts @@ -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(); \ No newline at end of file diff --git a/test/test.smartacme.integration.ts b/test/test.smartacme.integration.ts new file mode 100644 index 0000000..426c01d --- /dev/null +++ b/test/test.smartacme.integration.ts @@ -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(); \ No newline at end of file diff --git a/test/test.smartacme.ts b/test/test.smartacme.ts new file mode 100644 index 0000000..595b6e4 --- /dev/null +++ b/test/test.smartacme.ts @@ -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 { + getSupportedTypes(): string[] { return ['dns-01']; } + async prepare(_: any): Promise { /* no-op */ } + async cleanup(_: any): Promise { /* 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(); \ No newline at end of file diff --git a/test/test.ts b/test/test.ts deleted file mode 100644 index 641393a..0000000 --- a/test/test.ts +++ /dev/null @@ -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(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 493111b..46eba3c 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartacme', - version: '5.1.0', + version: '6.0.0', description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.' } diff --git a/ts/handlers/Dns01Handler.ts b/ts/handlers/Dns01Handler.ts new file mode 100644 index 0000000..3cc2328 --- /dev/null +++ b/ts/handlers/Dns01Handler.ts @@ -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 { + 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 { + // 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 { + // remove DNS TXT record + await this.cf.convenience.acmeRemoveDnsChallenge(ch); + } +} \ No newline at end of file diff --git a/ts/handlers/Http01Handler.ts b/ts/handlers/Http01Handler.ts new file mode 100644 index 0000000..8d3240b --- /dev/null +++ b/ts/handlers/Http01Handler.ts @@ -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 /.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 { + 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 { + // Optional: implement HTTP polling if desired + return; + } + + public async cleanup(ch: { token: string; webPath: string }): Promise { + const relWebPath = ch.webPath.replace(/^\/+/, ''); + const filePath = path.join(this.webroot, relWebPath); + try { + await fs.unlink(filePath); + } catch { + // ignore missing file + } + } +} \ No newline at end of file diff --git a/ts/handlers/IChallengeHandler.ts b/ts/handlers/IChallengeHandler.ts new file mode 100644 index 0000000..a6e5876 --- /dev/null +++ b/ts/handlers/IChallengeHandler.ts @@ -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 { + /** + * 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; + /** + * Optional extra verify step (HTTP GET, ALPN handshake). + */ + verify?(ch: T): Promise; + /** + * Clean up resources: remove DNS record, stop server. + */ + cleanup(ch: T): Promise; +} \ No newline at end of file diff --git a/ts/handlers/index.ts b/ts/handlers/index.ts new file mode 100644 index 0000000..69073e3 --- /dev/null +++ b/ts/handlers/index.ts @@ -0,0 +1,4 @@ +export type { IChallengeHandler } from './IChallengeHandler.js'; +// Removed legacy handler adapter +export { Dns01Handler } from './Dns01Handler.js'; +export { Http01Handler } from './Http01Handler.js'; \ No newline at end of file diff --git a/ts/smartacme.classes.smartacme.ts b/ts/smartacme.classes.smartacme.ts index 03186f9..7fb8ff5 100644 --- a/ts/smartacme.classes.smartacme.ts +++ b/ts/smartacme.classes.smartacme.ts @@ -11,8 +11,7 @@ export interface ISmartAcmeOptions { accountPrivateKey?: string; accountEmail: string; mongoDescriptor: plugins.smartdata.IMongoDescriptor; - setChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise; - removeChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise; + // 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[]; + /** + * 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; - private removeChallenge: (dnsChallengeArg: plugins.tsclass.network.IDnsChallenge) => Promise; // 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[]; + // 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 { - 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 } | 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); } } } diff --git a/ts/smartacme.plugins.ts b/ts/smartacme.plugins.ts index 728609c..73797d6 100644 --- a/ts/smartacme.plugins.ts +++ b/ts/smartacme.plugins.ts @@ -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 }; diff --git a/tsconfig.json b/tsconfig.json index dc20ee9..b2d0154 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,12 @@ "baseUrl": ".", "paths": {} }, + "include": [ + "ts/**/*.ts" + ], "exclude": [ + "node_modules", + "test", "dist_*/**/*.d.ts" ] } \ No newline at end of file