2025-05-09 17:00:27 +00:00
|
|
|
import * as plugins from '../../plugins.js';
|
|
|
|
import { IncomingMessage, ServerResponse } from 'http';
|
2025-05-09 17:28:27 +00:00
|
|
|
import {
|
|
|
|
CertificateEvents
|
2025-05-09 17:00:27 +00:00
|
|
|
} from '../../certificate/events/certificate-events.js';
|
|
|
|
import type {
|
2025-05-09 22:46:53 +00:00
|
|
|
ICertificateData,
|
|
|
|
ICertificateFailure,
|
|
|
|
ICertificateExpiring
|
2025-05-09 17:00:27 +00:00
|
|
|
} from '../../certificate/models/certificate-types.js';
|
2025-05-09 17:28:27 +00:00
|
|
|
import type {
|
2025-05-09 22:46:53 +00:00
|
|
|
ISmartAcme,
|
|
|
|
ISmartAcmeCert,
|
|
|
|
ISmartAcmeOptions,
|
|
|
|
IHttp01MemoryHandler
|
2025-05-09 17:28:27 +00:00
|
|
|
} from './acme-interfaces.js';
|
2025-05-09 17:00:27 +00:00
|
|
|
|
|
|
|
/**
|
2025-05-09 17:28:27 +00:00
|
|
|
* ChallengeResponder handles ACME HTTP-01 challenges by leveraging SmartAcme
|
|
|
|
* It acts as a bridge between the HTTP server and the ACME challenge verification process
|
2025-05-09 17:00:27 +00:00
|
|
|
*/
|
|
|
|
export class ChallengeResponder extends plugins.EventEmitter {
|
2025-05-09 22:46:53 +00:00
|
|
|
private smartAcme: ISmartAcme | null = null;
|
|
|
|
private http01Handler: IHttp01MemoryHandler | null = null;
|
2025-05-09 17:00:27 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates a new challenge responder
|
|
|
|
* @param useProduction Whether to use production ACME servers
|
|
|
|
* @param email Account email for ACME
|
|
|
|
* @param certificateStore Directory to store certificates
|
|
|
|
*/
|
|
|
|
constructor(
|
|
|
|
private readonly useProduction: boolean = false,
|
|
|
|
private readonly email: string = 'admin@example.com',
|
|
|
|
private readonly certificateStore: string = './certs'
|
|
|
|
) {
|
|
|
|
super();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Initialize the ACME client
|
|
|
|
*/
|
|
|
|
public async initialize(): Promise<void> {
|
|
|
|
try {
|
2025-05-09 17:28:27 +00:00
|
|
|
// Create the HTTP-01 memory handler from SmartACME
|
2025-05-09 17:10:19 +00:00
|
|
|
this.http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler();
|
2025-05-09 17:28:27 +00:00
|
|
|
|
|
|
|
// Ensure certificate store directory exists
|
|
|
|
await this.ensureCertificateStore();
|
|
|
|
|
|
|
|
// Create a MemoryCertManager for certificate storage
|
|
|
|
const certManager = new plugins.smartacme.certmanagers.MemoryCertManager();
|
|
|
|
|
|
|
|
// Initialize the SmartACME client with appropriate options
|
2025-05-09 17:00:27 +00:00
|
|
|
this.smartAcme = new plugins.smartacme.SmartAcme({
|
|
|
|
accountEmail: this.email,
|
2025-05-09 17:28:27 +00:00
|
|
|
certManager: certManager,
|
2025-05-09 17:10:19 +00:00
|
|
|
environment: this.useProduction ? 'production' : 'integration',
|
|
|
|
challengeHandlers: [this.http01Handler],
|
2025-05-09 17:28:27 +00:00
|
|
|
challengePriority: ['http-01']
|
2025-05-09 17:00:27 +00:00
|
|
|
});
|
|
|
|
|
2025-05-09 17:10:19 +00:00
|
|
|
// Set up event forwarding from SmartAcme
|
2025-05-09 17:28:27 +00:00
|
|
|
this.setupEventListeners();
|
|
|
|
|
|
|
|
// Start the SmartACME client
|
2025-05-09 17:10:19 +00:00
|
|
|
await this.smartAcme.start();
|
2025-05-09 17:28:27 +00:00
|
|
|
console.log('ACME client initialized successfully');
|
|
|
|
} catch (error) {
|
|
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
|
|
throw new Error(`Failed to initialize ACME client: ${errorMessage}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Ensure the certificate store directory exists
|
|
|
|
*/
|
|
|
|
private async ensureCertificateStore(): Promise<void> {
|
|
|
|
try {
|
|
|
|
await plugins.fs.promises.mkdir(this.certificateStore, { recursive: true });
|
2025-05-09 17:10:19 +00:00
|
|
|
} catch (error) {
|
2025-05-09 17:28:27 +00:00
|
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
|
|
throw new Error(`Failed to create certificate store: ${errorMessage}`);
|
2025-05-09 17:10:19 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2025-05-09 17:28:27 +00:00
|
|
|
* Setup event listeners to forward SmartACME events to our own event emitter
|
2025-05-09 17:10:19 +00:00
|
|
|
*/
|
2025-05-09 17:28:27 +00:00
|
|
|
private setupEventListeners(): void {
|
2025-05-09 17:10:19 +00:00
|
|
|
if (!this.smartAcme) return;
|
|
|
|
|
2025-05-09 17:28:27 +00:00
|
|
|
const setupEvents = (emitter: { on: (event: string, listener: (data: any) => void) => void }) => {
|
|
|
|
// Forward certificate events
|
|
|
|
emitter.on('certificate', (data: any) => {
|
|
|
|
const isRenewal = !!data.isRenewal;
|
2025-05-09 17:10:19 +00:00
|
|
|
|
2025-05-09 22:46:53 +00:00
|
|
|
const certData: ICertificateData = {
|
2025-05-09 17:28:27 +00:00
|
|
|
domain: data.domainName || data.domain,
|
|
|
|
certificate: data.publicKey || data.cert,
|
|
|
|
privateKey: data.privateKey || data.key,
|
|
|
|
expiryDate: new Date(data.validUntil || data.expiryDate || Date.now()),
|
|
|
|
source: 'http01',
|
|
|
|
isRenewal
|
2025-05-09 17:00:27 +00:00
|
|
|
};
|
|
|
|
|
2025-05-09 17:28:27 +00:00
|
|
|
const eventType = isRenewal
|
2025-05-09 17:10:19 +00:00
|
|
|
? CertificateEvents.CERTIFICATE_RENEWED
|
|
|
|
: CertificateEvents.CERTIFICATE_ISSUED;
|
2025-05-09 17:28:27 +00:00
|
|
|
|
2025-05-09 17:10:19 +00:00
|
|
|
this.emit(eventType, certData);
|
2025-05-09 17:00:27 +00:00
|
|
|
});
|
|
|
|
|
2025-05-09 17:28:27 +00:00
|
|
|
// Forward error events
|
|
|
|
emitter.on('error', (error: any) => {
|
|
|
|
const domain = error.domainName || error.domain || 'unknown';
|
2025-05-09 22:46:53 +00:00
|
|
|
const failureData: ICertificateFailure = {
|
2025-05-09 17:28:27 +00:00
|
|
|
domain,
|
|
|
|
error: error.message || String(error),
|
|
|
|
isRenewal: !!error.isRenewal
|
2025-05-09 17:00:27 +00:00
|
|
|
};
|
2025-05-09 17:28:27 +00:00
|
|
|
|
|
|
|
this.emit(CertificateEvents.CERTIFICATE_FAILED, failureData);
|
2025-05-09 17:00:27 +00:00
|
|
|
});
|
2025-05-09 17:28:27 +00:00
|
|
|
};
|
2025-05-09 17:00:27 +00:00
|
|
|
|
2025-05-09 17:28:27 +00:00
|
|
|
// Check for direct event methods on SmartAcme
|
|
|
|
if (typeof this.smartAcme.on === 'function') {
|
|
|
|
setupEvents(this.smartAcme as any);
|
|
|
|
}
|
|
|
|
// Check for eventEmitter property
|
|
|
|
else if (this.smartAcme.eventEmitter) {
|
|
|
|
setupEvents(this.smartAcme.eventEmitter);
|
|
|
|
}
|
|
|
|
// If no proper event handling, log a warning
|
|
|
|
else {
|
|
|
|
console.warn('SmartAcme instance does not support expected event interface - events may not be forwarded');
|
2025-05-09 17:00:27 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2025-05-09 17:28:27 +00:00
|
|
|
* Handle HTTP request by checking if it's an ACME challenge
|
|
|
|
* @param req HTTP request object
|
|
|
|
* @param res HTTP response object
|
|
|
|
* @returns true if the request was handled, false otherwise
|
2025-05-09 17:00:27 +00:00
|
|
|
*/
|
|
|
|
public handleRequest(req: IncomingMessage, res: ServerResponse): boolean {
|
2025-05-09 17:28:27 +00:00
|
|
|
if (!this.http01Handler) return false;
|
2025-05-09 17:10:19 +00:00
|
|
|
|
2025-05-09 17:28:27 +00:00
|
|
|
// Check if this is an ACME challenge request (/.well-known/acme-challenge/*)
|
|
|
|
const url = req.url || '';
|
2025-05-09 17:00:27 +00:00
|
|
|
if (url.startsWith('/.well-known/acme-challenge/')) {
|
2025-05-09 17:28:27 +00:00
|
|
|
try {
|
|
|
|
// Delegate to the HTTP-01 memory handler, which knows how to serve challenges
|
|
|
|
this.http01Handler.handleRequest(req, res);
|
|
|
|
return true;
|
|
|
|
} catch (error) {
|
|
|
|
console.error('Error handling ACME challenge:', error);
|
|
|
|
// If there was an error, send a 404 response
|
|
|
|
res.writeHead(404);
|
|
|
|
res.end('Not found');
|
|
|
|
return true;
|
2025-05-09 17:00:27 +00:00
|
|
|
}
|
|
|
|
}
|
2025-05-09 17:28:27 +00:00
|
|
|
|
2025-05-09 17:00:27 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Request a certificate for a domain
|
2025-05-09 17:28:27 +00:00
|
|
|
* @param domain Domain name to request a certificate for
|
|
|
|
* @param isRenewal Whether this is a renewal request
|
2025-05-09 17:00:27 +00:00
|
|
|
*/
|
2025-05-09 22:46:53 +00:00
|
|
|
public async requestCertificate(domain: string, isRenewal: boolean = false): Promise<ICertificateData> {
|
2025-05-09 17:00:27 +00:00
|
|
|
if (!this.smartAcme) {
|
|
|
|
throw new Error('ACME client not initialized');
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
2025-05-09 17:28:27 +00:00
|
|
|
// Request certificate using SmartACME
|
2025-05-09 17:10:19 +00:00
|
|
|
const certObj = await this.smartAcme.getCertificateForDomain(domain);
|
2025-05-09 17:00:27 +00:00
|
|
|
|
2025-05-09 17:28:27 +00:00
|
|
|
// Convert the certificate object to our CertificateData format
|
2025-05-09 22:46:53 +00:00
|
|
|
const certData: ICertificateData = {
|
2025-05-09 17:00:27 +00:00
|
|
|
domain,
|
2025-05-09 17:10:19 +00:00
|
|
|
certificate: certObj.publicKey,
|
|
|
|
privateKey: certObj.privateKey,
|
|
|
|
expiryDate: new Date(certObj.validUntil),
|
|
|
|
source: 'http01',
|
|
|
|
isRenewal
|
2025-05-09 17:00:27 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
return certData;
|
|
|
|
} catch (error) {
|
2025-05-09 17:28:27 +00:00
|
|
|
// Create failure object
|
2025-05-09 22:46:53 +00:00
|
|
|
const failure: ICertificateFailure = {
|
2025-05-09 17:00:27 +00:00
|
|
|
domain,
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
2025-05-09 17:28:27 +00:00
|
|
|
isRenewal
|
2025-05-09 17:00:27 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
// Emit failure event
|
|
|
|
this.emit(CertificateEvents.CERTIFICATE_FAILED, failure);
|
|
|
|
|
2025-05-09 17:28:27 +00:00
|
|
|
// Rethrow with more context
|
2025-05-09 17:00:27 +00:00
|
|
|
throw new Error(`Failed to ${isRenewal ? 'renew' : 'obtain'} certificate for ${domain}: ${
|
|
|
|
error instanceof Error ? error.message : String(error)
|
|
|
|
}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2025-05-09 17:28:27 +00:00
|
|
|
* Check if a certificate is expiring soon and trigger renewal if needed
|
2025-05-09 17:00:27 +00:00
|
|
|
* @param domain Domain name
|
|
|
|
* @param certificate Certificate data
|
2025-05-09 17:28:27 +00:00
|
|
|
* @param thresholdDays Days before expiry to trigger renewal
|
2025-05-09 17:00:27 +00:00
|
|
|
*/
|
|
|
|
public checkCertificateExpiry(
|
|
|
|
domain: string,
|
2025-05-09 22:46:53 +00:00
|
|
|
certificate: ICertificateData,
|
2025-05-09 17:00:27 +00:00
|
|
|
thresholdDays: number = 30
|
|
|
|
): void {
|
2025-05-09 17:28:27 +00:00
|
|
|
if (!certificate.expiryDate) return;
|
2025-05-09 17:00:27 +00:00
|
|
|
|
|
|
|
const now = new Date();
|
|
|
|
const expiryDate = certificate.expiryDate;
|
|
|
|
const daysDifference = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
|
|
|
|
|
if (daysDifference <= thresholdDays) {
|
2025-05-09 22:46:53 +00:00
|
|
|
const expiryInfo: ICertificateExpiring = {
|
2025-05-09 17:00:27 +00:00
|
|
|
domain,
|
|
|
|
expiryDate,
|
2025-05-09 17:28:27 +00:00
|
|
|
daysRemaining: daysDifference
|
2025-05-09 17:00:27 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
this.emit(CertificateEvents.CERTIFICATE_EXPIRING, expiryInfo);
|
|
|
|
|
|
|
|
// Automatically attempt renewal if expiring
|
|
|
|
if (this.smartAcme) {
|
|
|
|
this.requestCertificate(domain, true).catch(error => {
|
|
|
|
console.error(`Failed to auto-renew certificate for ${domain}:`, error);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|