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:
2025-04-27 14:28:05 +00:00
parent 48018b8955
commit 58015f0b58
16 changed files with 411 additions and 143 deletions

View File

@ -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.'
}

View 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);
}
}

View 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
}
}
}

View 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
View 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';

View File

@ -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);
}
}
}

View File

@ -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 };