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:
@ -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 };
|
||||
|
Reference in New Issue
Block a user