2025-03-06 08:27:44 +00:00
|
|
|
import * as plugins from './plugins.js';
|
2025-02-24 09:53:39 +00:00
|
|
|
|
2025-03-06 08:27:44 +00:00
|
|
|
/**
|
|
|
|
* Represents a domain certificate with various status information
|
|
|
|
*/
|
2025-02-24 09:53:39 +00:00
|
|
|
interface IDomainCertificate {
|
|
|
|
certObtained: boolean;
|
|
|
|
obtainingInProgress: boolean;
|
|
|
|
certificate?: string;
|
|
|
|
privateKey?: string;
|
|
|
|
challengeToken?: string;
|
|
|
|
challengeKeyAuthorization?: string;
|
2025-03-06 08:27:44 +00:00
|
|
|
expiryDate?: Date;
|
|
|
|
lastRenewalAttempt?: Date;
|
2025-02-24 09:53:39 +00:00
|
|
|
}
|
|
|
|
|
2025-03-06 08:27:44 +00:00
|
|
|
/**
|
|
|
|
* Configuration options for the ACME Certificate Manager
|
|
|
|
*/
|
|
|
|
interface IAcmeCertManagerOptions {
|
|
|
|
port?: number;
|
|
|
|
contactEmail?: string;
|
|
|
|
useProduction?: boolean;
|
|
|
|
renewThresholdDays?: number;
|
|
|
|
httpsRedirectPort?: number;
|
|
|
|
renewCheckIntervalHours?: number;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Certificate data that can be emitted via events or set from outside
|
|
|
|
*/
|
|
|
|
interface ICertificateData {
|
|
|
|
domain: string;
|
|
|
|
certificate: string;
|
|
|
|
privateKey: string;
|
|
|
|
expiryDate: Date;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Events emitted by the ACME Certificate Manager
|
|
|
|
*/
|
|
|
|
export enum CertManagerEvents {
|
|
|
|
CERTIFICATE_ISSUED = 'certificate-issued',
|
|
|
|
CERTIFICATE_RENEWED = 'certificate-renewed',
|
|
|
|
CERTIFICATE_FAILED = 'certificate-failed',
|
|
|
|
CERTIFICATE_EXPIRING = 'certificate-expiring',
|
|
|
|
MANAGER_STARTED = 'manager-started',
|
|
|
|
MANAGER_STOPPED = 'manager-stopped',
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Improved ACME Certificate Manager with event emission and external certificate management
|
|
|
|
*/
|
|
|
|
export class AcmeCertManager extends plugins.EventEmitter {
|
2025-02-24 09:53:39 +00:00
|
|
|
private domainCertificates: Map<string, IDomainCertificate>;
|
2025-03-06 08:27:44 +00:00
|
|
|
private server: plugins.http.Server | null = null;
|
|
|
|
private acmeClient: plugins.acme.Client | null = null;
|
2025-02-24 09:53:39 +00:00
|
|
|
private accountKey: string | null = null;
|
2025-03-06 08:27:44 +00:00
|
|
|
private renewalTimer: NodeJS.Timeout | null = null;
|
|
|
|
private isShuttingDown: boolean = false;
|
|
|
|
private options: Required<IAcmeCertManagerOptions>;
|
2025-02-24 09:53:39 +00:00
|
|
|
|
2025-03-06 08:27:44 +00:00
|
|
|
/**
|
|
|
|
* Creates a new ACME Certificate Manager
|
|
|
|
* @param options Configuration options
|
|
|
|
*/
|
|
|
|
constructor(options: IAcmeCertManagerOptions = {}) {
|
|
|
|
super();
|
2025-02-24 09:53:39 +00:00
|
|
|
this.domainCertificates = new Map<string, IDomainCertificate>();
|
2025-03-06 08:27:44 +00:00
|
|
|
|
|
|
|
// Default options
|
|
|
|
this.options = {
|
|
|
|
port: options.port ?? 80,
|
|
|
|
contactEmail: options.contactEmail ?? 'admin@example.com',
|
|
|
|
useProduction: options.useProduction ?? false, // Safer default: staging
|
|
|
|
renewThresholdDays: options.renewThresholdDays ?? 30,
|
|
|
|
httpsRedirectPort: options.httpsRedirectPort ?? 443,
|
|
|
|
renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Starts the HTTP server for ACME challenges
|
|
|
|
*/
|
|
|
|
public async start(): Promise<void> {
|
|
|
|
if (this.server) {
|
|
|
|
throw new Error('Server is already running');
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.isShuttingDown) {
|
|
|
|
throw new Error('Server is shutting down');
|
|
|
|
}
|
2025-02-24 09:53:39 +00:00
|
|
|
|
2025-03-06 08:27:44 +00:00
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
try {
|
|
|
|
this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res));
|
|
|
|
|
|
|
|
this.server.on('error', (error: NodeJS.ErrnoException) => {
|
|
|
|
if (error.code === 'EACCES') {
|
|
|
|
reject(new Error(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`));
|
|
|
|
} else if (error.code === 'EADDRINUSE') {
|
|
|
|
reject(new Error(`Port ${this.options.port} is already in use.`));
|
|
|
|
} else {
|
|
|
|
reject(error);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
this.server.listen(this.options.port, () => {
|
|
|
|
console.log(`AcmeCertManager is listening on port ${this.options.port}`);
|
|
|
|
this.startRenewalTimer();
|
|
|
|
this.emit(CertManagerEvents.MANAGER_STARTED, this.options.port);
|
|
|
|
resolve();
|
|
|
|
});
|
|
|
|
} catch (error) {
|
|
|
|
reject(error);
|
|
|
|
}
|
2025-02-24 09:53:39 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2025-03-06 08:27:44 +00:00
|
|
|
* Stops the HTTP server and renewal timer
|
|
|
|
*/
|
|
|
|
public async stop(): Promise<void> {
|
|
|
|
if (!this.server) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.isShuttingDown = true;
|
|
|
|
|
|
|
|
// Stop the renewal timer
|
|
|
|
if (this.renewalTimer) {
|
|
|
|
clearInterval(this.renewalTimer);
|
|
|
|
this.renewalTimer = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return new Promise<void>((resolve) => {
|
|
|
|
if (this.server) {
|
|
|
|
this.server.close(() => {
|
|
|
|
this.server = null;
|
|
|
|
this.isShuttingDown = false;
|
|
|
|
this.emit(CertManagerEvents.MANAGER_STOPPED);
|
|
|
|
resolve();
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
this.isShuttingDown = false;
|
|
|
|
resolve();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Adds a domain to be managed for certificates
|
|
|
|
* @param domain The domain to add
|
2025-02-24 09:53:39 +00:00
|
|
|
*/
|
|
|
|
public addDomain(domain: string): void {
|
|
|
|
if (!this.domainCertificates.has(domain)) {
|
|
|
|
this.domainCertificates.set(domain, { certObtained: false, obtainingInProgress: false });
|
|
|
|
console.log(`Domain added: ${domain}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2025-03-06 08:27:44 +00:00
|
|
|
* Removes a domain from management
|
|
|
|
* @param domain The domain to remove
|
2025-02-24 09:53:39 +00:00
|
|
|
*/
|
|
|
|
public removeDomain(domain: string): void {
|
|
|
|
if (this.domainCertificates.delete(domain)) {
|
|
|
|
console.log(`Domain removed: ${domain}`);
|
|
|
|
}
|
|
|
|
}
|
2025-03-06 08:27:44 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Sets a certificate for a domain directly (for externally obtained certificates)
|
|
|
|
* @param domain The domain for the certificate
|
|
|
|
* @param certificate The certificate (PEM format)
|
|
|
|
* @param privateKey The private key (PEM format)
|
|
|
|
* @param expiryDate Optional expiry date
|
|
|
|
*/
|
|
|
|
public setCertificate(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void {
|
|
|
|
let domainInfo = this.domainCertificates.get(domain);
|
|
|
|
|
|
|
|
if (!domainInfo) {
|
|
|
|
domainInfo = { certObtained: false, obtainingInProgress: false };
|
|
|
|
this.domainCertificates.set(domain, domainInfo);
|
|
|
|
}
|
|
|
|
|
|
|
|
domainInfo.certificate = certificate;
|
|
|
|
domainInfo.privateKey = privateKey;
|
|
|
|
domainInfo.certObtained = true;
|
|
|
|
domainInfo.obtainingInProgress = false;
|
|
|
|
|
|
|
|
if (expiryDate) {
|
|
|
|
domainInfo.expiryDate = expiryDate;
|
|
|
|
} else {
|
|
|
|
// Try to extract expiry date from certificate
|
|
|
|
try {
|
|
|
|
// This is a simplistic approach - in a real implementation, use a proper
|
|
|
|
// certificate parsing library like node-forge or x509
|
|
|
|
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
|
|
|
if (matches && matches[1]) {
|
|
|
|
domainInfo.expiryDate = new Date(matches[1]);
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
console.warn(`Failed to extract expiry date from certificate for ${domain}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
console.log(`Certificate set for ${domain}`);
|
|
|
|
|
|
|
|
// Emit certificate event
|
|
|
|
this.emitCertificateEvent(CertManagerEvents.CERTIFICATE_ISSUED, {
|
|
|
|
domain,
|
|
|
|
certificate,
|
|
|
|
privateKey,
|
|
|
|
expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Gets the certificate for a domain if it exists
|
|
|
|
* @param domain The domain to get the certificate for
|
|
|
|
*/
|
|
|
|
public getCertificate(domain: string): ICertificateData | null {
|
|
|
|
const domainInfo = this.domainCertificates.get(domain);
|
|
|
|
|
|
|
|
if (!domainInfo || !domainInfo.certObtained || !domainInfo.certificate || !domainInfo.privateKey) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
domain,
|
|
|
|
certificate: domainInfo.certificate,
|
|
|
|
privateKey: domainInfo.privateKey,
|
|
|
|
expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default
|
|
|
|
};
|
|
|
|
}
|
2025-02-24 09:53:39 +00:00
|
|
|
|
|
|
|
/**
|
2025-03-06 08:27:44 +00:00
|
|
|
* Lazy initialization of the ACME client
|
|
|
|
* @returns An ACME client instance
|
2025-02-24 09:53:39 +00:00
|
|
|
*/
|
2025-03-06 08:27:44 +00:00
|
|
|
private async getAcmeClient(): Promise<plugins.acme.Client> {
|
2025-02-24 09:53:39 +00:00
|
|
|
if (this.acmeClient) {
|
|
|
|
return this.acmeClient;
|
|
|
|
}
|
2025-03-06 08:27:44 +00:00
|
|
|
|
|
|
|
// 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,
|
2025-02-24 09:53:39 +00:00
|
|
|
accountKey: this.accountKey,
|
|
|
|
});
|
2025-03-06 08:27:44 +00:00
|
|
|
|
|
|
|
// Create a new account
|
2025-02-24 09:53:39 +00:00
|
|
|
await this.acmeClient.createAccount({
|
|
|
|
termsOfServiceAgreed: true,
|
2025-03-06 08:27:44 +00:00
|
|
|
contact: [`mailto:${this.options.contactEmail}`],
|
2025-02-24 09:53:39 +00:00
|
|
|
});
|
2025-03-06 08:27:44 +00:00
|
|
|
|
2025-02-24 09:53:39 +00:00
|
|
|
return this.acmeClient;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2025-03-06 08:27:44 +00:00
|
|
|
* Handles incoming HTTP requests
|
|
|
|
* @param req The HTTP request
|
|
|
|
* @param res The HTTP response
|
2025-02-24 09:53:39 +00:00
|
|
|
*/
|
2025-03-06 08:27:44 +00:00
|
|
|
private handleRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
2025-02-24 09:53:39 +00:00
|
|
|
const hostHeader = req.headers.host;
|
|
|
|
if (!hostHeader) {
|
|
|
|
res.statusCode = 400;
|
|
|
|
res.end('Bad Request: Host header is missing');
|
|
|
|
return;
|
|
|
|
}
|
2025-03-06 08:27:44 +00:00
|
|
|
|
2025-02-24 09:53:39 +00:00
|
|
|
// Extract domain (ignoring any port in the Host header)
|
|
|
|
const domain = hostHeader.split(':')[0];
|
|
|
|
|
2025-03-06 08:27:44 +00:00
|
|
|
// If the request is for an ACME HTTP-01 challenge, handle it
|
2025-02-24 09:53:39 +00:00
|
|
|
if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) {
|
|
|
|
this.handleAcmeChallenge(req, res, domain);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!this.domainCertificates.has(domain)) {
|
|
|
|
res.statusCode = 404;
|
|
|
|
res.end('Domain not configured');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const domainInfo = this.domainCertificates.get(domain)!;
|
|
|
|
|
2025-03-06 08:27:44 +00:00
|
|
|
// If certificate exists, redirect to HTTPS
|
2025-02-24 09:53:39 +00:00
|
|
|
if (domainInfo.certObtained) {
|
2025-03-06 08:27:44 +00:00
|
|
|
const httpsPort = this.options.httpsRedirectPort;
|
|
|
|
const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`;
|
|
|
|
const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`;
|
|
|
|
|
2025-02-24 09:53:39 +00:00
|
|
|
res.statusCode = 301;
|
|
|
|
res.setHeader('Location', redirectUrl);
|
|
|
|
res.end(`Redirecting to ${redirectUrl}`);
|
|
|
|
} else {
|
2025-03-06 08:27:44 +00:00
|
|
|
// Trigger certificate issuance if not already running
|
2025-02-24 09:53:39 +00:00
|
|
|
if (!domainInfo.obtainingInProgress) {
|
|
|
|
this.obtainCertificate(domain).catch(err => {
|
2025-03-06 08:27:44 +00:00
|
|
|
this.emit(CertManagerEvents.CERTIFICATE_FAILED, { domain, error: err.message });
|
2025-02-24 09:53:39 +00:00
|
|
|
console.error(`Error obtaining certificate for ${domain}:`, err);
|
|
|
|
});
|
|
|
|
}
|
2025-03-06 08:27:44 +00:00
|
|
|
|
2025-02-24 09:53:39 +00:00
|
|
|
res.statusCode = 503;
|
|
|
|
res.end('Certificate issuance in progress, please try again later.');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2025-03-06 08:27:44 +00:00
|
|
|
* Serves the ACME HTTP-01 challenge response
|
|
|
|
* @param req The HTTP request
|
|
|
|
* @param res The HTTP response
|
|
|
|
* @param domain The domain for the challenge
|
2025-02-24 09:53:39 +00:00
|
|
|
*/
|
2025-03-06 08:27:44 +00:00
|
|
|
private handleAcmeChallenge(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse, domain: string): void {
|
2025-02-24 09:53:39 +00:00
|
|
|
const domainInfo = this.domainCertificates.get(domain);
|
|
|
|
if (!domainInfo) {
|
|
|
|
res.statusCode = 404;
|
|
|
|
res.end('Domain not configured');
|
|
|
|
return;
|
|
|
|
}
|
2025-03-06 08:27:44 +00:00
|
|
|
|
|
|
|
// The token is the last part of the URL
|
2025-02-24 09:53:39 +00:00
|
|
|
const urlParts = req.url?.split('/');
|
|
|
|
const token = urlParts ? urlParts[urlParts.length - 1] : '';
|
2025-03-06 08:27:44 +00:00
|
|
|
|
2025-02-24 09:53:39 +00:00
|
|
|
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');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2025-03-06 08:27:44 +00:00
|
|
|
* 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
|
2025-02-24 09:53:39 +00:00
|
|
|
*/
|
2025-03-06 08:27:44 +00:00
|
|
|
private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> {
|
|
|
|
// Get the domain info
|
|
|
|
const domainInfo = this.domainCertificates.get(domain);
|
|
|
|
if (!domainInfo) {
|
|
|
|
throw new Error(`Domain not found: ${domain}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Prevent concurrent certificate issuance
|
|
|
|
if (domainInfo.obtainingInProgress) {
|
|
|
|
console.log(`Certificate issuance already in progress for ${domain}`);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
domainInfo.obtainingInProgress = true;
|
|
|
|
domainInfo.lastRenewalAttempt = new Date();
|
|
|
|
|
2025-02-24 09:53:39 +00:00
|
|
|
try {
|
|
|
|
const client = await this.getAcmeClient();
|
|
|
|
|
2025-03-06 08:27:44 +00:00
|
|
|
// Create a new order for the domain
|
2025-02-24 09:53:39 +00:00
|
|
|
const order = await client.createOrder({
|
|
|
|
identifiers: [{ type: 'dns', value: domain }],
|
|
|
|
});
|
|
|
|
|
2025-03-06 08:27:44 +00:00
|
|
|
// Get the authorizations for the order
|
2025-02-24 09:53:39 +00:00
|
|
|
const authorizations = await client.getAuthorizations(order);
|
2025-03-06 08:27:44 +00:00
|
|
|
|
2025-02-24 09:53:39 +00:00
|
|
|
for (const authz of authorizations) {
|
|
|
|
const challenge = authz.challenges.find(ch => ch.type === 'http-01');
|
|
|
|
if (!challenge) {
|
|
|
|
throw new Error('HTTP-01 challenge not found');
|
|
|
|
}
|
2025-03-06 08:27:44 +00:00
|
|
|
|
|
|
|
// Get the key authorization for the challenge
|
2025-02-24 09:53:39 +00:00
|
|
|
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
|
2025-03-06 08:27:44 +00:00
|
|
|
|
|
|
|
// Store the challenge data
|
2025-02-24 09:53:39 +00:00
|
|
|
domainInfo.challengeToken = challenge.token;
|
|
|
|
domainInfo.challengeKeyAuthorization = keyAuthorization;
|
|
|
|
|
2025-03-06 08:27:44 +00:00
|
|
|
// 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) {
|
|
|
|
console.error(`Challenge error for ${domain}:`, error);
|
|
|
|
throw error;
|
|
|
|
}
|
2025-02-24 09:53:39 +00:00
|
|
|
}
|
|
|
|
|
2025-03-06 08:27:44 +00:00
|
|
|
// Generate a CSR and private key
|
|
|
|
const [csrBuffer, privateKeyBuffer] = await plugins.acme.forge.createCsr({
|
2025-02-24 09:53:39 +00:00
|
|
|
commonName: domain,
|
|
|
|
});
|
2025-03-06 08:27:44 +00:00
|
|
|
|
2025-02-24 10:00:57 +00:00
|
|
|
const csr = csrBuffer.toString();
|
|
|
|
const privateKey = privateKeyBuffer.toString();
|
2025-02-24 09:53:39 +00:00
|
|
|
|
2025-03-06 08:27:44 +00:00
|
|
|
// Finalize the order with our CSR
|
2025-02-24 09:53:39 +00:00
|
|
|
await client.finalizeOrder(order, csr);
|
2025-03-06 08:27:44 +00:00
|
|
|
|
|
|
|
// Get the certificate with the full chain
|
2025-02-24 09:53:39 +00:00
|
|
|
const certificate = await client.getCertificate(order);
|
|
|
|
|
2025-03-06 08:27:44 +00:00
|
|
|
// Store the certificate and key
|
2025-02-24 09:53:39 +00:00
|
|
|
domainInfo.certificate = certificate;
|
|
|
|
domainInfo.privateKey = privateKey;
|
|
|
|
domainInfo.certObtained = true;
|
2025-03-06 08:27:44 +00:00
|
|
|
|
|
|
|
// Clear challenge data
|
2025-02-24 09:53:39 +00:00
|
|
|
delete domainInfo.challengeToken;
|
|
|
|
delete domainInfo.challengeKeyAuthorization;
|
2025-03-06 08:27:44 +00:00
|
|
|
|
|
|
|
// Extract expiry date from certificate
|
|
|
|
try {
|
|
|
|
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
|
|
|
|
if (matches && matches[1]) {
|
|
|
|
domainInfo.expiryDate = new Date(matches[1]);
|
|
|
|
console.log(`Certificate for ${domain} will expire on ${domainInfo.expiryDate.toISOString()}`);
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
console.warn(`Failed to extract expiry date from certificate for ${domain}`);
|
|
|
|
}
|
2025-02-24 09:53:39 +00:00
|
|
|
|
2025-03-06 08:27:44 +00:00
|
|
|
console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
|
|
|
|
|
|
|
|
// Emit the appropriate event
|
|
|
|
const eventType = isRenewal
|
|
|
|
? CertManagerEvents.CERTIFICATE_RENEWED
|
|
|
|
: CertManagerEvents.CERTIFICATE_ISSUED;
|
|
|
|
|
|
|
|
this.emitCertificateEvent(eventType, {
|
|
|
|
domain,
|
|
|
|
certificate,
|
|
|
|
privateKey,
|
|
|
|
expiryDate: domainInfo.expiryDate || new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days default
|
|
|
|
});
|
|
|
|
|
|
|
|
} 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);
|
2025-02-24 09:53:39 +00:00
|
|
|
}
|
2025-03-06 08:27:44 +00:00
|
|
|
|
|
|
|
// Emit failure event
|
|
|
|
this.emit(CertManagerEvents.CERTIFICATE_FAILED, {
|
|
|
|
domain,
|
|
|
|
error: error.message || 'Unknown error',
|
|
|
|
isRenewal
|
|
|
|
});
|
|
|
|
} finally {
|
|
|
|
// Reset flag whether successful or not
|
|
|
|
domainInfo.obtainingInProgress = false;
|
2025-02-24 09:53:39 +00:00
|
|
|
}
|
|
|
|
}
|
2025-03-06 08:27:44 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Starts the certificate renewal timer
|
|
|
|
*/
|
|
|
|
private startRenewalTimer(): void {
|
|
|
|
if (this.renewalTimer) {
|
|
|
|
clearInterval(this.renewalTimer);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Convert hours to milliseconds
|
|
|
|
const checkInterval = this.options.renewCheckIntervalHours * 60 * 60 * 1000;
|
|
|
|
|
|
|
|
this.renewalTimer = setInterval(() => this.checkForRenewals(), checkInterval);
|
|
|
|
|
|
|
|
// Prevent the timer from keeping the process alive
|
|
|
|
if (this.renewalTimer.unref) {
|
|
|
|
this.renewalTimer.unref();
|
|
|
|
}
|
|
|
|
|
|
|
|
console.log(`Certificate renewal check scheduled every ${this.options.renewCheckIntervalHours} hours`);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Checks for certificates that need renewal
|
|
|
|
*/
|
|
|
|
private checkForRenewals(): void {
|
|
|
|
if (this.isShuttingDown) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
console.log('Checking for certificates that need renewal...');
|
|
|
|
|
|
|
|
const now = new Date();
|
|
|
|
const renewThresholdMs = this.options.renewThresholdDays * 24 * 60 * 60 * 1000;
|
|
|
|
|
|
|
|
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
|
|
|
|
// Skip domains without certificates or already in renewal
|
|
|
|
if (!domainInfo.certObtained || domainInfo.obtainingInProgress) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Skip domains without expiry dates
|
|
|
|
if (!domainInfo.expiryDate) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
const timeUntilExpiry = domainInfo.expiryDate.getTime() - now.getTime();
|
|
|
|
|
|
|
|
// Check if certificate is near expiry
|
|
|
|
if (timeUntilExpiry <= renewThresholdMs) {
|
|
|
|
console.log(`Certificate for ${domain} expires soon, renewing...`);
|
|
|
|
this.emit(CertManagerEvents.CERTIFICATE_EXPIRING, {
|
|
|
|
domain,
|
|
|
|
expiryDate: domainInfo.expiryDate,
|
|
|
|
daysRemaining: Math.ceil(timeUntilExpiry / (24 * 60 * 60 * 1000))
|
|
|
|
});
|
|
|
|
|
|
|
|
// Start renewal process
|
|
|
|
this.obtainCertificate(domain, true).catch(err => {
|
|
|
|
console.error(`Error renewing certificate for ${domain}:`, err);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Emits a certificate event with the certificate data
|
|
|
|
* @param eventType The event type to emit
|
|
|
|
* @param data The certificate data
|
|
|
|
*/
|
|
|
|
private emitCertificateEvent(eventType: CertManagerEvents, data: ICertificateData): void {
|
|
|
|
this.emit(eventType, data);
|
|
|
|
}
|
|
|
|
}
|