Files
smartacme/ts/smartacme.classes.smartacme.ts

577 lines
22 KiB
TypeScript
Raw Permalink Normal View History

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';
2019-01-09 00:01:01 +01:00
// ── 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;
2019-01-06 20:41:21 +01:00
/**
2019-01-12 19:12:52 +01:00
* the options for the class @see SmartAcme
2019-01-06 20:41:21 +01:00
*/
2019-01-08 20:45:35 +01:00
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`
2019-01-13 21:40:40 +01:00
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<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[];
/**
* 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;
2019-01-08 20:45:35 +01:00
}
2017-01-02 00:18:51 +01:00
2019-01-12 13:52:21 +01:00
/**
2019-01-12 19:11:39 +01:00
* class SmartAcme
* can be used for setting up communication with an ACME authority
2019-01-15 23:39:31 +01:00
*
2019-01-12 13:52:21 +01:00
* ```ts
* const mySmartAcmeInstance = new SmartAcme({
* // see ISmartAcmeOptions for options
* })
* ```
*/
2019-01-06 20:41:21 +01:00
export class SmartAcme {
2019-01-08 20:45:35 +01:00
private options: ISmartAcmeOptions;
2019-01-06 20:41:21 +01:00
// the acme client
private client: plugins.acme.AcmeClient;
private smartdns = new plugins.smartdnsClient.Smartdns({});
public logger: plugins.smartlog.Smartlog;
2016-11-01 20:16:43 +01:00
2019-01-06 20:41:21 +01:00
// the account private key
private privateKey: string;
2016-11-07 18:41:52 +01:00
2016-11-01 20:16:43 +01:00
// certificate manager for persistence (implements ICertManager)
public certmanager: ICertManager;
// configured pluggable ACME challenge handlers
public challengeHandlers: plugins.handlers.IChallengeHandler<any>[];
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<undefined, typeof CERT_ISSUANCE_STEPS>;
// bound signal handlers so they can be removed on stop()
private boundSigintHandler: (() => void) | null = null;
private boundSigtermHandler: (() => void) | null = null;
2019-01-14 02:46:36 +01:00
/**
* Exposes the aggregated task event stream for observing certificate issuance progress.
*/
public get certIssuanceEvents(): plugins.taskbuffer.TaskManager['taskSubject'] {
return this.taskManager.taskSubject;
}
2019-01-08 20:45:35 +01:00
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);
2019-01-08 20:45:35 +01:00
}
2019-01-09 00:01:01 +01:00
/**
* starts the instance
2019-01-12 13:44:18 +01:00
* ```ts
* await myCloudlyInstance.start() // does not support options
2019-01-12 13:44:18 +01:00
* ```
2019-01-09 00:01:01 +01:00
*/
public async start() {
2019-01-09 00:01:01 +01:00
this.privateKey =
this.options.accountPrivateKey || plugins.acme.AcmeCrypto.createRsaPrivateKey();
2019-01-08 20:45:35 +01:00
// Initialize certificate manager
if (!this.options.certManager) {
throw new Error('You must provide a certManager via options.certManager');
}
this.certmanager = this.options.certManager;
2019-01-08 20:45:35 +01:00
await this.certmanager.init();
2019-01-13 02:10:00 +01:00
// CertMatcher
this.certmatcher = new SmartacmeCertMatcher();
2019-01-13 02:10:00 +01:00
2019-01-09 00:01:01 +01:00
// ACME Client
this.client = new plugins.acme.AcmeClient({
2019-01-13 21:40:40 +01:00
directoryUrl: (() => {
2019-01-15 23:39:31 +01:00
if (this.options.environment === 'production') {
return plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.production;
2019-01-13 21:40:40 +01:00
} else {
return plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.staging;
2019-01-13 21:40:40 +01:00
}
})(),
accountKeyPem: this.privateKey,
logger: (level, message, data) => {
this.logger.log(level as any, message, data);
},
2019-01-06 20:41:21 +01:00
});
/* Register account */
await this.client.createAccount({
termsOfServiceAgreed: true,
2020-08-12 16:36:06 +00:00
contact: [`mailto:${this.options.accountEmail}`],
2019-01-06 20:41:21 +01:00
});
// 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<T>(operation: () => Promise<T>, operationName: string = 'operation'): Promise<T> {
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<void> {
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));
});
}
2019-01-12 13:44:18 +01:00
2019-01-17 22:50:21 +01:00
/**
* gets a certificate
* it runs through the following steps
2019-02-06 09:47:33 +01:00
*
2019-01-17 22:50:21 +01:00
* * look in the database
* * if in the database and still valid return it
* * if not in the database announce it
2019-01-17 22:50:21 +01:00
* * then get it from letsencrypt
* * store it
* * retrieve it from the database and return it
2019-02-06 09:47:33 +01:00
*
2019-01-17 22:50:21 +01:00
* @param domainArg
* @param options Optional configuration for certificate generation
2019-01-17 22:50:21 +01:00
*/
public async getCertificateForDomain(
domainArg: string,
options?: { includeWildcard?: boolean }
): Promise<SmartacmeCert> {
2025-05-04 10:29:33 +00:00
// 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);
2025-05-04 10:29:33 +00:00
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');
}
}
2025-05-04 10:29:33 +00:00
// Retrieve any existing certificate record by base domain.
const retrievedCertificate = await this.certmanager.retrieveCertificate(certDomainName);
if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) {
2019-01-08 20:45:35 +01:00
return retrievedCertificate;
2020-02-10 20:36:01 +00:00
} else if (retrievedCertificate && retrievedCertificate.shouldBeRenewed()) {
// Remove old certificate via certManager
await this.certmanager.deleteCertificate(certDomainName);
2019-01-08 20:45:35 +01:00
}
// 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<SmartacmeCert> {
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');
2019-01-06 20:41:21 +01:00
// ── Step: authorize ───────────────────────────────────────────────────
this.certIssuanceTask.notifyStep('authorize');
2019-01-06 20:41:21 +01:00
/* Get authorizations and select challenges */
const authorizations = await this.retry(() => this.client.getAuthorizations(order), 'getAuthorizations');
2019-01-06 20:41:21 +01:00
2019-01-06 23:54:46 +01:00
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<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 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);
2019-01-06 20:41:21 +01:00
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,
);
}
2019-01-06 20:41:21 +01:00
} 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);
2019-01-06 20:41:21 +01:00
}
}
2019-01-06 23:54:46 +01:00
}
2019-01-06 20:41:21 +01:00
// ── 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,
2019-01-06 20:41:21 +01:00
});
await this.retry(() => this.client.finalizeOrder(order, csr), 'finalizeOrder');
const cert = await this.retry(() => this.client.getCertificate(order), 'getCertificate');
2019-01-06 20:41:21 +01:00
// ── Step: store ───────────────────────────────────────────────────────
this.certIssuanceTask.notifyStep('store');
2019-01-08 20:45:35 +01:00
const certRecord = new SmartacmeCert({
2019-01-16 22:34:38 +01:00
id: plugins.smartunique.shortId(),
domainName: certDomainName,
2019-01-12 13:44:18 +01:00
privateKey: key.toString(),
publicKey: cert.toString(),
csr: csr.toString(),
created: Date.now(),
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 90 }),
2019-01-12 13:44:18 +01:00
});
await this.certmanager.storeCertificate(certRecord);
2019-01-12 13:44:18 +01:00
const newCertificate = await this.certmanager.retrieveCertificate(certDomainName);
return newCertificate ?? certRecord;
2017-04-28 18:56:55 +02:00
}
2016-11-01 18:27:57 +01:00
}