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