import * as plugins from './plugins.js'; import type { ICertManager } from './interfaces/certmanager.js'; import { SmartacmeCertMatcher } from './smartacme.classes.certmatcher.js'; import { commitinfo } from './00_commitinfo_data.js'; import { SmartacmeCert } from './smartacme.classes.cert.js'; // ── Types & constants for certificate issuance task ────────────────────────── interface ICertIssuanceInput { certDomainName: string; domainArg: string; isWildcardRequest: boolean; includeWildcard: boolean; } const CERT_ISSUANCE_STEPS = [ { name: 'prepare', description: 'Creating ACME order', percentage: 10 }, { name: 'authorize', description: 'Solving ACME challenges', percentage: 40 }, { name: 'finalize', description: 'Finalizing and getting cert', percentage: 30 }, { name: 'store', description: 'Storing certificate', percentage: 20 }, ] as const; /** * the options for the class @see SmartAcme */ export interface ISmartAcmeOptions { accountEmail: string; accountPrivateKey?: string; /** * Certificate storage manager (e.g., Mongo or in-memory). */ certManager: ICertManager; // Removed legacy setChallenge/removeChallenge in favor of `challengeHandlers` environment: 'production' | 'integration'; /** * Optional retry/backoff configuration for transient failures */ retryOptions?: { /** number of retry attempts */ retries?: number; /** backoff multiplier */ factor?: number; /** initial delay in milliseconds */ minTimeoutMs?: number; /** maximum delay cap in milliseconds */ maxTimeoutMs?: number; }; /** * Pluggable ACME challenge handlers (DNS-01, HTTP-01, TLS-ALPN-01, etc.) */ challengeHandlers?: plugins.handlers.IChallengeHandler[]; /** * Order of challenge types to try (e.g. ['http-01','dns-01']). * Defaults to ['dns-01'] or first supported type from handlers. */ challengePriority?: string[]; /** * Maximum number of concurrent ACME issuances across all domains. * Defaults to 5. */ maxConcurrentIssuances?: number; /** * Maximum ACME orders allowed within the sliding window. * Defaults to 250 (conservative limit under Let's Encrypt's 300/3h). */ maxOrdersPerWindow?: number; /** * Sliding window duration in milliseconds for rate limiting. * Defaults to 3 hours (10_800_000 ms). */ orderWindowMs?: number; } /** * class SmartAcme * can be used for setting up communication with an ACME authority * * ```ts * const mySmartAcmeInstance = new SmartAcme({ * // see ISmartAcmeOptions for options * }) * ``` */ export class SmartAcme { private options: ISmartAcmeOptions; // the acme client private client: plugins.acme.AcmeClient; private smartdns = new plugins.smartdnsClient.Smartdns({}); public logger: plugins.smartlog.Smartlog; // the account private key private privateKey: string; // certificate manager for persistence (implements ICertManager) public certmanager: ICertManager; // configured pluggable ACME challenge handlers public challengeHandlers: plugins.handlers.IChallengeHandler[]; private certmatcher: SmartacmeCertMatcher; // retry/backoff configuration (resolved with defaults) private retryOptions: { retries: number; factor: number; minTimeoutMs: number; maxTimeoutMs: number }; // track pending DNS challenges for graceful shutdown private pendingChallenges: plugins.tsclass.network.IDnsChallenge[] = []; // priority order of challenge types private challengePriority: string[]; // TaskManager for coordinating concurrent certificate requests private taskManager: plugins.taskbuffer.TaskManager; // Single reusable task for certificate issuance private certIssuanceTask: plugins.taskbuffer.Task; // bound signal handlers so they can be removed on stop() private boundSigintHandler: (() => void) | null = null; private boundSigtermHandler: (() => void) | null = null; /** * Exposes the aggregated task event stream for observing certificate issuance progress. */ public get certIssuanceEvents(): plugins.taskbuffer.TaskManager['taskSubject'] { return this.taskManager.taskSubject; } constructor(optionsArg: ISmartAcmeOptions) { this.options = optionsArg; this.logger = plugins.smartlog.Smartlog.createForCommitinfo(commitinfo); // enable console output for structured logging this.logger.enableConsole(); // initialize retry/backoff options this.retryOptions = { retries: optionsArg.retryOptions?.retries ?? 10, factor: optionsArg.retryOptions?.factor ?? 4, minTimeoutMs: optionsArg.retryOptions?.minTimeoutMs ?? 1000, maxTimeoutMs: optionsArg.retryOptions?.maxTimeoutMs ?? 60000, }; // 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]); // ── TaskManager setup ────────────────────────────────────────────────── this.taskManager = new plugins.taskbuffer.TaskManager(); // Constraint 1: Per-domain mutex — one issuance at a time per TLD, with result sharing const certDomainMutex = new plugins.taskbuffer.TaskConstraintGroup({ name: 'cert-domain-mutex', maxConcurrent: 1, resultSharingMode: 'share-latest', constraintKeyForExecution: (_task, input?: ICertIssuanceInput) => { return input?.certDomainName ?? null; }, shouldExecute: async (_task, input?: ICertIssuanceInput) => { if (!input?.certDomainName || !this.certmanager) return true; // Safety net: if a valid cert is already cached, skip re-issuance const existing = await this.certmanager.retrieveCertificate(input.certDomainName); if (existing && !existing.shouldBeRenewed()) { return false; } return true; }, }); // Constraint 2: Global concurrency cap const acmeGlobalConcurrency = new plugins.taskbuffer.TaskConstraintGroup({ name: 'acme-global-concurrency', maxConcurrent: optionsArg.maxConcurrentIssuances ?? 5, constraintKeyForExecution: () => 'global', }); // Constraint 3: Account-level rate limiting const acmeAccountRateLimit = new plugins.taskbuffer.TaskConstraintGroup({ name: 'acme-account-rate-limit', rateLimit: { maxPerWindow: optionsArg.maxOrdersPerWindow ?? 250, windowMs: optionsArg.orderWindowMs ?? 10_800_000, }, constraintKeyForExecution: () => 'account', }); this.taskManager.addConstraintGroup(certDomainMutex); this.taskManager.addConstraintGroup(acmeGlobalConcurrency); this.taskManager.addConstraintGroup(acmeAccountRateLimit); // Create the single reusable certificate issuance task this.certIssuanceTask = new plugins.taskbuffer.Task({ name: 'cert-issuance', steps: CERT_ISSUANCE_STEPS, taskFunction: async (input: ICertIssuanceInput) => { return this.performCertificateIssuance(input); }, }); this.taskManager.addTask(this.certIssuanceTask); } /** * starts the instance * ```ts * await myCloudlyInstance.start() // does not support options * ``` */ public async start() { this.privateKey = this.options.accountPrivateKey || plugins.acme.AcmeCrypto.createRsaPrivateKey(); // Initialize certificate manager if (!this.options.certManager) { throw new Error('You must provide a certManager via options.certManager'); } this.certmanager = this.options.certManager; await this.certmanager.init(); // CertMatcher this.certmatcher = new SmartacmeCertMatcher(); // ACME Client this.client = new plugins.acme.AcmeClient({ directoryUrl: (() => { if (this.options.environment === 'production') { return plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.production; } else { return plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.staging; } })(), accountKeyPem: this.privateKey, logger: (level, message, data) => { this.logger.log(level as any, message, data); }, }); /* Register account */ await this.client.createAccount({ termsOfServiceAgreed: true, contact: [`mailto:${this.options.accountEmail}`], }); // Start the task manager await this.taskManager.start(); // 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; } // Stop the task manager await this.taskManager.stop(); // Destroy ACME HTTP transport (closes keep-alive sockets) if (this.client) { this.client.destroy(); } // Destroy DNS client (kills Rust bridge child process if spawned) if (this.smartdns) { this.smartdns.destroy(); } if (this.certmanager && typeof (this.certmanager as any).close === 'function') { await (this.certmanager as any).close(); } } /** Retry helper with exponential backoff and AcmeError awareness */ private async retry(operation: () => Promise, operationName: string = 'operation'): Promise { let attempt = 0; let delay = this.retryOptions.minTimeoutMs; while (true) { 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); throw err; } await this.logger.log('warn', `Operation ${operationName} failed on attempt ${attempt}, retrying in ${delay}ms`, err); await plugins.smartdelay.delayFor(delay); delay = Math.min(delay * this.retryOptions.factor, this.retryOptions.maxTimeoutMs); } } } /** Clean up pending challenges and shut down */ private async handleShutdown(): Promise { 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 = []; await this.stop(); } /** Handle process signals for graceful shutdown */ private handleSignal(sig: string): void { this.logger.log('info', `Received signal ${sig}, shutting down gracefully`); this.handleShutdown() .then(() => process.exit(0)) .catch((err) => { this.logger.log('error', 'Error during shutdown', err).then(() => process.exit(1)); }); } /** * gets a certificate * it runs through the following steps * * * look in the database * * if in the database and still valid return it * * if not in the database announce it * * then get it from letsencrypt * * store it * * retrieve it from the database and return it * * @param domainArg * @param options Optional configuration for certificate generation */ public async getCertificateForDomain( domainArg: string, options?: { includeWildcard?: boolean } ): Promise { // Determine if this is a wildcard request (e.g., '*.example.com'). const isWildcardRequest = domainArg.startsWith('*.'); // Determine the base domain for certificate retrieval/issuance. const certDomainName = this.certmatcher.getCertificateDomainNameByDomainName(domainArg); if (!certDomainName) { throw new Error(`Cannot determine certificate domain for ${domainArg}`); } // Wildcard certificates require DNS-01 challenge support. if (isWildcardRequest) { const hasDnsHandler = this.challengeHandlers.some((h) => h.getSupportedTypes().includes('dns-01'), ); if (!hasDnsHandler) { throw new Error('Wildcard certificate requests require a DNS-01 challenge handler'); } } // Retrieve any existing certificate record by base domain. const retrievedCertificate = await this.certmanager.retrieveCertificate(certDomainName); if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) { return retrievedCertificate; } else if (retrievedCertificate && retrievedCertificate.shouldBeRenewed()) { // Remove old certificate via certManager await this.certmanager.deleteCertificate(certDomainName); } // Build issuance input and trigger the constrained task const issuanceInput: ICertIssuanceInput = { certDomainName, domainArg, isWildcardRequest, includeWildcard: options?.includeWildcard ?? false, }; const result = await this.taskManager.triggerTaskConstrained( this.certIssuanceTask, issuanceInput, ); // If we got a cert directly (either from execution or result sharing), return it if (result != null) { return result; } // If shouldExecute returned false (cert appeared in cache), read from cache const cachedCert = await this.certmanager.retrieveCertificate(certDomainName); if (cachedCert) { return cachedCert; } throw new Error(`Certificate issuance failed for ${certDomainName}`); } /** * Performs the actual ACME certificate issuance flow. * Called by the certIssuanceTask's taskFunction. */ private async performCertificateIssuance(input: ICertIssuanceInput): Promise { const { certDomainName, isWildcardRequest, includeWildcard } = input; // ── Step: prepare ───────────────────────────────────────────────────── this.certIssuanceTask.notifyStep('prepare'); // Build identifiers array based on request const identifiers: Array<{ type: 'dns'; value: string }> = []; if (isWildcardRequest) { identifiers.push({ type: 'dns', value: `*.${certDomainName}` }); identifiers.push({ type: 'dns', value: certDomainName }); } else { identifiers.push({ type: 'dns', value: certDomainName }); if (includeWildcard) { const hasDnsHandler = this.challengeHandlers.some((h) => h.getSupportedTypes().includes('dns-01'), ); if (!hasDnsHandler) { this.logger.log('warn', 'Wildcard certificate requested but no DNS-01 handler available. Skipping wildcard.'); } else { identifiers.push({ type: 'dns', value: `*.${certDomainName}` }); } } } /* Place new order with retry */ const order = await this.retry(() => this.client.createOrder({ identifiers, }), 'createOrder'); // ── Step: authorize ─────────────────────────────────────────────────── this.certIssuanceTask.notifyStep('authorize'); /* Get authorizations and select challenges */ const authorizations = await this.retry(() => this.client.getAuthorizations(order), 'getAuthorizations'); for (const authz of authorizations) { await this.logger.log('debug', 'Authorization received', authz); // select a handler based on configured priority let selectedHandler: { type: string; handler: plugins.handlers.IChallengeHandler } | 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 challengeInput: any; // retrieve keyAuthorization for challenge const keyAuth = await this.client.getChallengeKeyAuthorization(selectedChallengeArg); if (type === 'dns-01') { challengeInput = { type, hostName: `_acme-challenge.${authz.identifier.value}`, challenge: keyAuth }; } else if (type === 'http-01') { challengeInput = { type, token: (selectedChallengeArg as any).token, keyAuthorization: keyAuth, webPath: `/.well-known/acme-challenge/${(selectedChallengeArg as any).token}`, }; } else { challengeInput = { type, keyAuthorization: keyAuth, ...selectedChallengeArg }; } this.pendingChallenges.push(challengeInput); try { await this.retry(() => handler.prepare(challengeInput), `${type}.prepare`); if (type === 'dns-01') { const dnsInput = challengeInput as { hostName: string; challenge: string }; await this.retry( () => this.smartdns.checkUntilAvailable(dnsInput.hostName, 'TXT', dnsInput.challenge, 100, 5000), `${type}.propagation`, ); this.logger.log('info', 'Cooling down for 1 minute before ACME verification'); await plugins.smartdelay.delayFor(60000); } await this.retry( () => this.client.completeChallenge(selectedChallengeArg), `${type}.completeChallenge`, ); try { await this.retry( () => this.client.waitForValidStatus(selectedChallengeArg), `${type}.waitForValidStatus`, ); } catch (err) { await this.logger.log( 'warn', `Challenge ${type} did not reach valid status in time, proceeding to finalize`, err, ); } } finally { try { await this.retry(() => handler.cleanup(challengeInput), `${type}.cleanup`); } catch (err) { await this.logger.log('error', `Error during ${type}.cleanup`, err); } finally { this.pendingChallenges = this.pendingChallenges.filter((c) => c !== challengeInput); } } } // ── Step: finalize ──────────────────────────────────────────────────── this.certIssuanceTask.notifyStep('finalize'); const csrDomains: string[] = []; let commonName: string; if (isWildcardRequest) { commonName = `*.${certDomainName}`; csrDomains.push(certDomainName); } else { commonName = certDomainName; if (includeWildcard && identifiers.some(id => id.value === `*.${certDomainName}`)) { csrDomains.push(`*.${certDomainName}`); } } const [key, csr] = await plugins.acme.AcmeCrypto.createCsr({ commonName, altNames: csrDomains, }); await this.retry(() => this.client.finalizeOrder(order, csr), 'finalizeOrder'); const cert = await this.retry(() => this.client.getCertificate(order), 'getCertificate'); // ── Step: store ─────────────────────────────────────────────────────── this.certIssuanceTask.notifyStep('store'); const certRecord = new SmartacmeCert({ id: plugins.smartunique.shortId(), domainName: certDomainName, privateKey: key.toString(), publicKey: cert.toString(), csr: csr.toString(), created: Date.now(), validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 90 }), }); await this.certmanager.storeCertificate(certRecord); const newCertificate = await this.certmanager.retrieveCertificate(certDomainName); return newCertificate ?? certRecord; } }