feat(ACME/Certificate): Introduce certificate provider hook and observable certificate events; remove legacy ACME flow

This commit is contained in:
2025-05-01 12:13:18 +00:00
parent e6a138279d
commit 0d8740d812
8 changed files with 186 additions and 231 deletions

View File

@ -74,8 +74,6 @@ interface IDomainCertificate {
obtainingInProgress: boolean;
certificate?: string;
privateKey?: string;
challengeToken?: string;
challengeKeyAuthorization?: string;
expiryDate?: Date;
lastRenewalAttempt?: Date;
}
@ -94,7 +92,6 @@ interface IPort80HandlerOptions {
autoRenew?: boolean; // Whether to automatically renew certificates
certificateStore?: string; // Directory to store certificates
skipConfiguredCerts?: boolean; // Skip domains that already have certificates
mongoDescriptor?: plugins.smartdata.IMongoDescriptor; // MongoDB config for SmartAcme
}
/**
@ -149,8 +146,6 @@ export class Port80Handler extends plugins.EventEmitter {
// SmartAcme instance for certificate management
private smartAcme: plugins.smartacme.SmartAcme | null = null;
private server: plugins.http.Server | null = null;
private acmeClient: plugins.acme.Client | null = null;
private accountKey: string | null = null;
private renewalTimer: NodeJS.Timeout | null = null;
private isShuttingDown: boolean = false;
private options: Required<IPort80HandlerOptions>;
@ -197,13 +192,10 @@ export class Port80Handler extends plugins.EventEmitter {
}
// Initialize SmartAcme for ACME challenge management (diskless HTTP handler)
if (this.options.enabled) {
if (!this.options.mongoDescriptor) {
throw new ServerError('MongoDB descriptor is required for SmartAcme');
}
this.smartAcme = new plugins.smartacme.SmartAcme({
accountEmail: this.options.contactEmail,
certManager: new plugins.smartacme.MemoryCertManager(),
environment: this.options.useProduction ? 'production' : 'integration',
mongoDescriptor: this.options.mongoDescriptor,
challengeHandlers: [ new DisklessHttp01Handler(this.acmeHttp01Storage) ],
challengePriority: ['http-01'],
});
@ -613,38 +605,6 @@ export class Port80Handler extends plugins.EventEmitter {
}
}
/**
* Lazy initialization of the ACME client
* @returns An ACME client instance
*/
private async getAcmeClient(): Promise<plugins.acme.Client> {
if (this.acmeClient) {
return this.acmeClient;
}
try {
// Generate a new account key
this.accountKey = (await plugins.acme.forge.createPrivateKey()).toString();
this.acmeClient = new plugins.acme.Client({
directoryUrl: this.options.useProduction
? plugins.acme.directory.letsencrypt.production
: plugins.acme.directory.letsencrypt.staging,
accountKey: this.accountKey,
});
// Create a new account
await this.acmeClient.createAccount({
termsOfServiceAgreed: true,
contact: [`mailto:${this.options.contactEmail}`],
});
return this.acmeClient;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error initializing ACME client';
throw new Port80HandlerError(`Failed to initialize ACME client: ${message}`);
}
}
/**
* Handles incoming HTTP requests
@ -803,209 +763,73 @@ export class Port80Handler extends plugins.EventEmitter {
}
}
/**
* Serves the ACME HTTP-01 challenge response
* @param req The HTTP request
* @param res The HTTP response
* @param domain The domain for the challenge
*/
private handleAcmeChallenge(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse, domain: string): void {
const domainInfo = this.domainCertificates.get(domain);
if (!domainInfo) {
res.statusCode = 404;
res.end('Domain not configured');
return;
}
// The token is the last part of the URL
const urlParts = req.url?.split('/');
const token = urlParts ? urlParts[urlParts.length - 1] : '';
if (domainInfo.challengeToken === token && domainInfo.challengeKeyAuthorization) {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(domainInfo.challengeKeyAuthorization);
console.log(`Served ACME challenge response for ${domain}`);
} else {
res.statusCode = 404;
res.end('Challenge token not found');
}
}
/**
* Obtains a certificate for a domain using ACME HTTP-01 challenge
* @param domain The domain to obtain a certificate for
* @param isRenewal Whether this is a renewal attempt
*/
/**
* Obtains a certificate for a domain using SmartAcme HTTP-01 challenges
* @param domain The domain to obtain a certificate for
* @param isRenewal Whether this is a renewal attempt
*/
private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> {
// Don't allow certificate issuance for glob patterns
if (this.isGlobPattern(domain)) {
throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal);
}
// Get the domain info
const domainInfo = this.domainCertificates.get(domain);
if (!domainInfo) {
throw new CertificateError('Domain not found', domain, isRenewal);
}
// Verify that acmeMaintenance is enabled
const domainInfo = this.domainCertificates.get(domain)!;
if (!domainInfo.options.acmeMaintenance) {
console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`);
return;
}
// Prevent concurrent certificate issuance
if (domainInfo.obtainingInProgress) {
console.log(`Certificate issuance already in progress for ${domain}`);
return;
}
if (!this.smartAcme) {
throw new Port80HandlerError('SmartAcme is not initialized');
}
domainInfo.obtainingInProgress = true;
domainInfo.lastRenewalAttempt = new Date();
try {
const client = await this.getAcmeClient();
// Create a new order for the domain
const order = await client.createOrder({
identifiers: [{ type: 'dns', value: domain }],
});
// Get the authorizations for the order
const authorizations = await client.getAuthorizations(order);
// Process each authorization
await this.processAuthorizations(client, domain, authorizations);
// Generate a CSR and private key
const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({
commonName: domain,
});
const csr = csrBuffer.toString();
const privateKey = privateKeyBuffer.toString();
// Finalize the order with our CSR
await client.finalizeOrder(order, csr);
// Get the certificate with the full chain
const certificate = await client.getCertificate(order);
// Store the certificate and key
// Request certificate via SmartAcme
const certObj = await this.smartAcme.getCertificateForDomain(domain);
const certificate = certObj.publicKey;
const privateKey = certObj.privateKey;
const expiryDate = new Date(certObj.validUntil);
domainInfo.certificate = certificate;
domainInfo.privateKey = privateKey;
domainInfo.certObtained = true;
// Clear challenge data
delete domainInfo.challengeToken;
delete domainInfo.challengeKeyAuthorization;
// Extract expiry date from certificate
domainInfo.expiryDate = this.extractExpiryDateFromCertificate(certificate, domain);
domainInfo.expiryDate = expiryDate;
console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
// Save the certificate to the store if enabled
if (this.options.certificateStore) {
this.saveCertificateToStore(domain, certificate, privateKey);
}
// Emit the appropriate event
const eventType = isRenewal
? Port80HandlerEvents.CERTIFICATE_RENEWED
const eventType = isRenewal
? Port80HandlerEvents.CERTIFICATE_RENEWED
: Port80HandlerEvents.CERTIFICATE_ISSUED;
this.emitCertificateEvent(eventType, {
domain,
certificate,
privateKey,
expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
expiryDate: expiryDate || this.getDefaultExpiryDate()
});
} catch (error: any) {
// Check for rate limit errors
if (error.message && (
error.message.includes('rateLimited') ||
error.message.includes('too many certificates') ||
error.message.includes('rate limit')
)) {
console.error(`Rate limit reached for ${domain}. Waiting before retry.`);
} else {
console.error(`Error during certificate issuance for ${domain}:`, error);
}
// Emit failure event
const errorMsg = error?.message || 'Unknown error';
console.error(`Error during certificate issuance for ${domain}:`, error);
this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
domain,
error: error.message || 'Unknown error',
error: errorMsg,
isRenewal
} as ICertificateFailure);
throw new CertificateError(
error.message || 'Certificate issuance failed',
domain,
isRenewal
);
throw new CertificateError(errorMsg, domain, isRenewal);
} finally {
// Reset flag whether successful or not
domainInfo.obtainingInProgress = false;
}
}
/**
* Process ACME authorizations by verifying and completing challenges
* @param client ACME client
* @param domain Domain name
* @param authorizations Authorizations to process
*/
private async processAuthorizations(
client: plugins.acme.Client,
domain: string,
authorizations: plugins.acme.Authorization[]
): Promise<void> {
const domainInfo = this.domainCertificates.get(domain);
if (!domainInfo) {
throw new CertificateError('Domain not found during authorization', domain);
}
for (const authz of authorizations) {
const challenge = authz.challenges.find(ch => ch.type === 'http-01');
if (!challenge) {
throw new CertificateError('HTTP-01 challenge not found', domain);
}
// Get the key authorization for the challenge
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
// Store the challenge data
domainInfo.challengeToken = challenge.token;
domainInfo.challengeKeyAuthorization = keyAuthorization;
// ACME client type definition workaround - use compatible approach
// First check if challenge verification is needed
const authzUrl = authz.url;
try {
// Check if authzUrl exists and perform verification
if (authzUrl) {
await client.verifyChallenge(authz, challenge);
}
// Complete the challenge
await client.completeChallenge(challenge);
// Wait for validation
await client.waitForValidStatus(challenge);
console.log(`HTTP-01 challenge completed for ${domain}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown challenge error';
console.error(`Challenge error for ${domain}:`, error);
throw new CertificateError(`Challenge verification failed: ${errorMessage}`, domain);
}
}
}
/**
* Starts the certificate renewal timer
*/