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