BREAKING CHANGE(acme): Replace external acme-client with a built-in RFC8555-compliant ACME implementation and update public APIs accordingly

This commit is contained in:
2026-02-15 20:20:46 +00:00
parent 3fa34fa373
commit cf4b758800
31 changed files with 4717 additions and 3530 deletions

View File

@@ -54,7 +54,7 @@ export class SmartAcme {
private options: ISmartAcmeOptions;
// the acme client
private client: plugins.acme.Client;
private client: plugins.acme.AcmeClient;
private smartdns = new plugins.smartdnsClient.Smartdns({});
public logger: plugins.smartlog.Smartlog;
@@ -77,6 +77,9 @@ export class SmartAcme {
private challengePriority: string[];
// Map for coordinating concurrent certificate requests
private interestMap: plugins.lik.InterestMap<string, SmartacmeCert>;
// bound signal handlers so they can be removed on stop()
private boundSigintHandler: (() => void) | null = null;
private boundSigtermHandler: (() => void) | null = null;
constructor(optionsArg: ISmartAcmeOptions) {
this.options = optionsArg;
@@ -114,7 +117,7 @@ export class SmartAcme {
*/
public async start() {
this.privateKey =
this.options.accountPrivateKey || (await plugins.acme.forge.createPrivateKey()).toString();
this.options.accountPrivateKey || plugins.acme.AcmeCrypto.createRsaPrivateKey();
// Initialize certificate manager
if (!this.options.certManager) {
@@ -127,15 +130,18 @@ export class SmartAcme {
this.certmatcher = new SmartacmeCertMatcher();
// ACME Client
this.client = new plugins.acme.Client({
this.client = new plugins.acme.AcmeClient({
directoryUrl: (() => {
if (this.options.environment === 'production') {
return plugins.acme.directory.letsencrypt.production;
return plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.production;
} else {
return plugins.acme.directory.letsencrypt.staging;
return plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.staging;
}
})(),
accountKey: this.privateKey,
accountKeyPem: this.privateKey,
logger: (level, message, data) => {
this.logger.log(level as any, message, data);
},
});
/* Register account */
@@ -143,20 +149,31 @@ export class SmartAcme {
termsOfServiceAgreed: true,
contact: [`mailto:${this.options.accountEmail}`],
});
// Setup graceful shutdown handlers
process.on('SIGINT', () => this.handleSignal('SIGINT'));
process.on('SIGTERM', () => this.handleSignal('SIGTERM'));
// Setup graceful shutdown handlers (store references for removal in stop())
this.boundSigintHandler = () => this.handleSignal('SIGINT');
this.boundSigtermHandler = () => this.handleSignal('SIGTERM');
process.on('SIGINT', this.boundSigintHandler);
process.on('SIGTERM', this.boundSigtermHandler);
}
/**
* Stops the SmartAcme instance and closes certificate store connections.
*/
public async stop() {
// Remove signal handlers so the process can exit cleanly
if (this.boundSigintHandler) {
process.removeListener('SIGINT', this.boundSigintHandler);
this.boundSigintHandler = null;
}
if (this.boundSigtermHandler) {
process.removeListener('SIGTERM', this.boundSigtermHandler);
this.boundSigtermHandler = null;
}
if (this.certmanager && typeof (this.certmanager as any).close === 'function') {
await (this.certmanager as any).close();
}
}
/** Retry helper with exponential backoff */
/** Retry helper with exponential backoff and AcmeError awareness */
private async retry<T>(operation: () => Promise<T>, operationName: string = 'operation'): Promise<T> {
let attempt = 0;
let delay = this.retryOptions.minTimeoutMs;
@@ -164,6 +181,19 @@ export class SmartAcme {
try {
return await operation();
} catch (err) {
// Check if it's a non-retryable ACME error — throw immediately
if (err instanceof plugins.acme.AcmeError) {
if (!err.isRetryable) {
await this.logger.log('error', `Operation ${operationName} failed with non-retryable error (${err.type}, HTTP ${err.status}) at ${err.url}`, err);
throw err;
}
// For rate-limited errors, use server-specified Retry-After delay
if (err.isRateLimited && err.retryAfter > 0) {
delay = err.retryAfter * 1000;
await this.logger.log('warn', `Operation ${operationName} rate-limited, Retry-After: ${err.retryAfter}s`, err);
}
}
attempt++;
if (attempt > this.retryOptions.retries) {
await this.logger.log('error', `Operation ${operationName} failed after ${attempt} attempts`, err);
@@ -347,11 +377,6 @@ export class SmartAcme {
this.logger.log('info', 'Cooling down for 1 minute before ACME verification');
await plugins.smartdelay.delayFor(60000);
}
// Official ACME verification (ensures challenge is publicly reachable)
await this.retry(
() => this.client.verifyChallenge(authz, selectedChallengeArg),
`${type}.verifyChallenge`,
);
// Notify ACME server to complete the challenge
await this.retry(
() => this.client.completeChallenge(selectedChallengeArg),
@@ -399,7 +424,7 @@ export class SmartAcme {
}
}
const [key, csr] = await plugins.acme.forge.createCsr({
const [key, csr] = await plugins.acme.AcmeCrypto.createCsr({
commonName,
altNames: csrDomains,
});