369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
import * as plugins from './smartacme.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';
|
|
|
|
/**
|
|
* the options for the class @see SmartAcme
|
|
*/
|
|
export interface ISmartAcmeOptions {
|
|
accountPrivateKey?: string;
|
|
accountEmail: 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<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[];
|
|
}
|
|
|
|
/**
|
|
* 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.Client;
|
|
private smartdns = new plugins.smartdnsClient.Smartdns({});
|
|
public logger: plugins.smartlog.Smartlog;
|
|
|
|
// the account private key
|
|
private privateKey: string;
|
|
|
|
|
|
// certificate manager for persistence (implements ICertManager)
|
|
private certmanager: ICertManager;
|
|
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[] = [];
|
|
// configured pluggable ACME challenge handlers
|
|
private challengeHandlers: plugins.handlers.IChallengeHandler<any>[];
|
|
// priority order of challenge types
|
|
private challengePriority: string[];
|
|
|
|
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]);
|
|
}
|
|
|
|
/**
|
|
* starts the instance
|
|
* ```ts
|
|
* await myCloudlyInstance.start() // does not support options
|
|
* ```
|
|
*/
|
|
public async start() {
|
|
this.privateKey =
|
|
this.options.accountPrivateKey || (await plugins.acme.forge.createPrivateKey()).toString();
|
|
|
|
// 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.Client({
|
|
directoryUrl: (() => {
|
|
if (this.options.environment === 'production') {
|
|
return plugins.acme.directory.letsencrypt.production;
|
|
} else {
|
|
return plugins.acme.directory.letsencrypt.staging;
|
|
}
|
|
})(),
|
|
accountKey: this.privateKey,
|
|
});
|
|
|
|
/* Register account */
|
|
await this.client.createAccount({
|
|
termsOfServiceAgreed: true,
|
|
contact: [`mailto:${this.options.accountEmail}`],
|
|
});
|
|
// Setup graceful shutdown handlers
|
|
process.on('SIGINT', () => this.handleSignal('SIGINT'));
|
|
process.on('SIGTERM', () => this.handleSignal('SIGTERM'));
|
|
}
|
|
|
|
/**
|
|
* Stops the SmartAcme instance and closes certificate store connections.
|
|
*/
|
|
public async stop() {
|
|
if (this.certmanager && typeof (this.certmanager as any).close === 'function') {
|
|
await (this.certmanager as any).close();
|
|
}
|
|
}
|
|
/** Retry helper with exponential backoff */
|
|
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) {
|
|
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));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* * remove it from the pending map (which it go onto by announcing it)
|
|
* * retrieve it from the databse and return it
|
|
*
|
|
* @param domainArg
|
|
*/
|
|
public async getCertificateForDomain(domainArg: string): Promise<SmartacmeCert> {
|
|
const certDomainName = this.certmatcher.getCertificateDomainNameByDomainName(domainArg);
|
|
const retrievedCertificate = await this.certmanager.retrieveCertificate(certDomainName);
|
|
|
|
if (
|
|
!retrievedCertificate &&
|
|
(await this.certmanager.interestMap.checkInterest(certDomainName))
|
|
) {
|
|
const existingCertificateInterest = this.certmanager.interestMap.findInterest(certDomainName);
|
|
const certificate = existingCertificateInterest.interestFullfilled;
|
|
return certificate;
|
|
} else if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) {
|
|
return retrievedCertificate;
|
|
} else if (retrievedCertificate && retrievedCertificate.shouldBeRenewed()) {
|
|
// Remove old certificate via certManager
|
|
await this.certmanager.deleteCertificate(certDomainName);
|
|
}
|
|
|
|
// lets make sure others get the same interest
|
|
const currentDomainInterst = await this.certmanager.interestMap.addInterest(certDomainName);
|
|
|
|
/* Place new order with retry */
|
|
const order = await this.retry(() => this.client.createOrder({
|
|
identifiers: [
|
|
{ type: 'dns', value: certDomainName },
|
|
{ type: 'dns', value: `*.${certDomainName}` },
|
|
],
|
|
}), 'createOrder');
|
|
|
|
/* 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<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 input: any;
|
|
// retrieve keyAuthorization for challenge
|
|
const keyAuth = await this.client.getChallengeKeyAuthorization(selectedChallengeArg);
|
|
if (type === 'dns-01') {
|
|
input = { type, hostName: `_acme-challenge.${authz.identifier.value}`, challenge: keyAuth };
|
|
} else if (type === 'http-01') {
|
|
// HTTP-01 requires serving token at webPath
|
|
input = {
|
|
type,
|
|
token: (selectedChallengeArg as any).token,
|
|
keyAuthorization: keyAuth,
|
|
webPath: `/.well-known/acme-challenge/${(selectedChallengeArg as any).token}`,
|
|
};
|
|
} else {
|
|
// generic challenge input: include raw challenge properties
|
|
input = { type, keyAuthorization: keyAuth, ...selectedChallengeArg };
|
|
}
|
|
this.pendingChallenges.push(input);
|
|
try {
|
|
// Prepare the challenge (set DNS record, write file, etc.)
|
|
await this.retry(() => handler.prepare(input), `${type}.prepare`);
|
|
// For DNS-01, wait for propagation before verification
|
|
if (type === 'dns-01') {
|
|
const dnsInput = input as { hostName: string; challenge: string };
|
|
// Wait for authoritative DNS propagation before ACME verify
|
|
await this.retry(
|
|
() => this.smartdns.checkUntilAvailable(dnsInput.hostName, 'TXT', dnsInput.challenge, 100, 5000),
|
|
`${type}.propagation`,
|
|
);
|
|
// Extra cool-down to ensure ACME server sees the new TXT record
|
|
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),
|
|
`${type}.completeChallenge`,
|
|
);
|
|
// Wait for valid status (warnings on staging timeouts)
|
|
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 {
|
|
// Always cleanup resource
|
|
try {
|
|
await this.retry(() => handler.cleanup(input), `${type}.cleanup`);
|
|
} catch (err) {
|
|
await this.logger.log('error', `Error during ${type}.cleanup`, err);
|
|
} finally {
|
|
this.pendingChallenges = this.pendingChallenges.filter((c) => c !== input);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Finalize order */
|
|
const [key, csr] = await plugins.acme.forge.createCsr({
|
|
commonName: `*.${certDomainName}`,
|
|
altNames: [certDomainName],
|
|
});
|
|
|
|
await this.retry(() => this.client.finalizeOrder(order, csr), 'finalizeOrder');
|
|
const cert = await this.retry(() => this.client.getCertificate(order), 'getCertificate');
|
|
|
|
/* Done */
|
|
|
|
// Store the new certificate record
|
|
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);
|
|
currentDomainInterst.fullfillInterest(newCertificate);
|
|
currentDomainInterst.destroy();
|
|
return newCertificate;
|
|
}
|
|
|
|
}
|