2025-05-09 17:00:27 +00:00
|
|
|
import * as plugins from '../../plugins.js';
|
|
|
|
import { IncomingMessage, ServerResponse } from 'http';
|
|
|
|
import {
|
|
|
|
CertificateEvents
|
|
|
|
} from '../../certificate/events/certificate-events.js';
|
|
|
|
import type {
|
|
|
|
CertificateData,
|
|
|
|
CertificateFailure,
|
|
|
|
CertificateExpiring
|
|
|
|
} from '../../certificate/models/certificate-types.js';
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Handles ACME HTTP-01 challenge responses
|
|
|
|
*/
|
|
|
|
export class ChallengeResponder extends plugins.EventEmitter {
|
|
|
|
private smartAcme: plugins.smartacme.SmartAcme | null = null;
|
|
|
|
private http01Handler: plugins.smartacme.handlers.Http01MemoryHandler | null = null;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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:10:19 +00:00
|
|
|
// Initialize HTTP-01 challenge handler
|
|
|
|
this.http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler();
|
|
|
|
|
|
|
|
// Initialize SmartAcme with proper options
|
2025-05-09 17:00:27 +00:00
|
|
|
this.smartAcme = new plugins.smartacme.SmartAcme({
|
|
|
|
accountEmail: this.email,
|
2025-05-09 17:10:19 +00:00
|
|
|
certManager: new plugins.smartacme.certmanagers.MemoryCertManager(),
|
|
|
|
environment: this.useProduction ? 'production' : 'integration',
|
|
|
|
challengeHandlers: [this.http01Handler],
|
|
|
|
challengePriority: ['http-01'],
|
2025-05-09 17:00:27 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
// Ensure certificate store directory exists
|
|
|
|
await this.ensureCertificateStore();
|
|
|
|
|
2025-05-09 17:10:19 +00:00
|
|
|
// Set up event forwarding from SmartAcme
|
|
|
|
this.setupEventForwarding();
|
|
|
|
|
|
|
|
// Start SmartAcme
|
|
|
|
await this.smartAcme.start();
|
|
|
|
} catch (error) {
|
|
|
|
throw new Error(`Failed to initialize ACME client: ${error instanceof Error ? error.message : String(error)}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Sets up event forwarding from SmartAcme to this component
|
|
|
|
*/
|
|
|
|
private setupEventForwarding(): void {
|
|
|
|
if (!this.smartAcme) return;
|
|
|
|
|
|
|
|
// Cast smartAcme to any since different versions have different event APIs
|
|
|
|
const smartAcmeAny = this.smartAcme as any;
|
|
|
|
|
|
|
|
// Forward certificate events to our own emitter
|
|
|
|
if (typeof smartAcmeAny.on === 'function') {
|
|
|
|
smartAcmeAny.on('certificate', (data: any) => {
|
2025-05-09 17:00:27 +00:00
|
|
|
const certData: CertificateData = {
|
|
|
|
domain: data.domain,
|
2025-05-09 17:10:19 +00:00
|
|
|
certificate: data.cert || data.publicKey,
|
|
|
|
privateKey: data.key || data.privateKey,
|
|
|
|
expiryDate: new Date(data.expiryDate || data.validUntil),
|
|
|
|
source: 'http01'
|
2025-05-09 17:00:27 +00:00
|
|
|
};
|
2025-05-09 17:10:19 +00:00
|
|
|
// Emit as issued or renewed based on the renewal flag
|
|
|
|
const eventType = data.isRenewal
|
|
|
|
? CertificateEvents.CERTIFICATE_RENEWED
|
|
|
|
: CertificateEvents.CERTIFICATE_ISSUED;
|
|
|
|
this.emit(eventType, certData);
|
2025-05-09 17:00:27 +00:00
|
|
|
});
|
|
|
|
|
2025-05-09 17:10:19 +00:00
|
|
|
smartAcmeAny.on('error', (data: any) => {
|
|
|
|
const failure: CertificateFailure = {
|
|
|
|
domain: data.domain || 'unknown',
|
|
|
|
error: data.message || data.toString(),
|
|
|
|
isRenewal: false
|
|
|
|
};
|
|
|
|
this.emit(CertificateEvents.CERTIFICATE_FAILED, failure);
|
|
|
|
});
|
|
|
|
} else if (smartAcmeAny.eventEmitter && typeof smartAcmeAny.eventEmitter.on === 'function') {
|
|
|
|
// Alternative event emitter approach for newer versions
|
|
|
|
smartAcmeAny.eventEmitter.on('certificate', (data: any) => {
|
2025-05-09 17:00:27 +00:00
|
|
|
const certData: CertificateData = {
|
|
|
|
domain: data.domain,
|
2025-05-09 17:10:19 +00:00
|
|
|
certificate: data.cert || data.publicKey,
|
|
|
|
privateKey: data.key || data.privateKey,
|
|
|
|
expiryDate: new Date(data.expiryDate || data.validUntil),
|
|
|
|
source: 'http01'
|
2025-05-09 17:00:27 +00:00
|
|
|
};
|
2025-05-09 17:10:19 +00:00
|
|
|
const eventType = data.isRenewal
|
|
|
|
? CertificateEvents.CERTIFICATE_RENEWED
|
|
|
|
: CertificateEvents.CERTIFICATE_ISSUED;
|
|
|
|
this.emit(eventType, certData);
|
2025-05-09 17:00:27 +00:00
|
|
|
});
|
|
|
|
|
2025-05-09 17:10:19 +00:00
|
|
|
smartAcmeAny.eventEmitter.on('error', (data: any) => {
|
|
|
|
const failure: CertificateFailure = {
|
|
|
|
domain: data.domain || 'unknown',
|
|
|
|
error: data.message || data.toString(),
|
|
|
|
isRenewal: false
|
2025-05-09 17:00:27 +00:00
|
|
|
};
|
2025-05-09 17:10:19 +00:00
|
|
|
this.emit(CertificateEvents.CERTIFICATE_FAILED, failure);
|
2025-05-09 17:00:27 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Ensure certificate store directory exists
|
|
|
|
*/
|
|
|
|
private async ensureCertificateStore(): Promise<void> {
|
|
|
|
try {
|
|
|
|
await plugins.fs.promises.mkdir(this.certificateStore, { recursive: true });
|
|
|
|
} catch (error) {
|
|
|
|
throw new Error(`Failed to create certificate store: ${error instanceof Error ? error.message : String(error)}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Handle HTTP request and check if it's an ACME challenge
|
|
|
|
* @param req HTTP request
|
|
|
|
* @param res HTTP response
|
|
|
|
* @returns true if the request was handled as an ACME challenge
|
|
|
|
*/
|
|
|
|
public handleRequest(req: IncomingMessage, res: ServerResponse): boolean {
|
|
|
|
if (!this.http01Handler) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
const url = req.url || '/';
|
2025-05-09 17:10:19 +00:00
|
|
|
|
2025-05-09 17:00:27 +00:00
|
|
|
// Check if this is an ACME challenge request
|
|
|
|
if (url.startsWith('/.well-known/acme-challenge/')) {
|
|
|
|
const token = url.split('/').pop() || '';
|
2025-05-09 17:10:19 +00:00
|
|
|
|
|
|
|
if (token && this.http01Handler) {
|
|
|
|
try {
|
|
|
|
// Try to delegate to the handler - casting to any for flexibility
|
|
|
|
const handler = this.http01Handler as any;
|
|
|
|
|
|
|
|
// Different versions may have different handler methods
|
|
|
|
if (typeof handler.handleChallenge === 'function') {
|
|
|
|
handler.handleChallenge(req, res);
|
|
|
|
return true;
|
|
|
|
} else if (typeof handler.handleRequest === 'function') {
|
|
|
|
// Some versions use handleRequest instead
|
|
|
|
handler.handleRequest(req, res);
|
|
|
|
return true;
|
|
|
|
} else {
|
|
|
|
// Fall back to manual response
|
|
|
|
const resp = this.getTokenResponse(token);
|
|
|
|
if (resp) {
|
|
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
|
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
|
|
|
|
res.writeHead(200);
|
|
|
|
res.end(resp);
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
// Challenge not found
|
2025-05-09 17:00:27 +00:00
|
|
|
}
|
|
|
|
}
|
2025-05-09 17:10:19 +00:00
|
|
|
|
2025-05-09 17:00:27 +00:00
|
|
|
// Invalid ACME challenge
|
|
|
|
res.writeHead(404);
|
|
|
|
res.end('Not found');
|
|
|
|
return true;
|
|
|
|
}
|
2025-05-09 17:10:19 +00:00
|
|
|
|
2025-05-09 17:00:27 +00:00
|
|
|
return false;
|
|
|
|
}
|
2025-05-09 17:10:19 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the response for a specific token if available
|
|
|
|
* This is a fallback method in case direct handler access isn't available
|
|
|
|
*/
|
|
|
|
private getTokenResponse(token: string): string | null {
|
|
|
|
if (!this.http01Handler) return null;
|
|
|
|
|
|
|
|
try {
|
|
|
|
// Cast to any to handle different versions of the API
|
|
|
|
const handler = this.http01Handler as any;
|
|
|
|
|
|
|
|
// Try different methods that might be available in different versions
|
|
|
|
if (typeof handler.getResponse === 'function') {
|
|
|
|
return handler.getResponse(token);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (typeof handler.getChallengeVerification === 'function') {
|
|
|
|
return handler.getChallengeVerification(token);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Try to access the challenges directly from the handler's internal state
|
|
|
|
if (handler.challenges && typeof handler.challenges === 'object' && handler.challenges[token]) {
|
|
|
|
return handler.challenges[token];
|
|
|
|
}
|
|
|
|
|
|
|
|
// Try the token map if it exists (another common pattern)
|
|
|
|
if (handler.tokenMap && typeof handler.tokenMap === 'object' && handler.tokenMap[token]) {
|
|
|
|
return handler.tokenMap[token];
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
console.error('Error getting token response:', err);
|
|
|
|
}
|
|
|
|
|
|
|
|
return null;
|
|
|
|
}
|
2025-05-09 17:00:27 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Request a certificate for a domain
|
|
|
|
* @param domain Domain name
|
|
|
|
* @param isRenewal Whether this is a renewal
|
|
|
|
*/
|
|
|
|
public async requestCertificate(domain: string, isRenewal: boolean = false): Promise<CertificateData> {
|
|
|
|
if (!this.smartAcme) {
|
|
|
|
throw new Error('ACME client not initialized');
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
2025-05-09 17:10:19 +00:00
|
|
|
// Request certificate via SmartAcme
|
|
|
|
const certObj = await this.smartAcme.getCertificateForDomain(domain);
|
2025-05-09 17:00:27 +00:00
|
|
|
|
|
|
|
const certData: CertificateData = {
|
|
|
|
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
|
|
|
};
|
|
|
|
|
2025-05-09 17:10:19 +00:00
|
|
|
// SmartACME will emit its own events, but we'll emit our own too
|
|
|
|
// for consistency with the rest of the system
|
2025-05-09 17:00:27 +00:00
|
|
|
if (isRenewal) {
|
|
|
|
this.emit(CertificateEvents.CERTIFICATE_RENEWED, certData);
|
|
|
|
} else {
|
|
|
|
this.emit(CertificateEvents.CERTIFICATE_ISSUED, certData);
|
|
|
|
}
|
|
|
|
|
|
|
|
return certData;
|
|
|
|
} catch (error) {
|
|
|
|
// Construct failure object
|
|
|
|
const failure: CertificateFailure = {
|
|
|
|
domain,
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
isRenewal,
|
|
|
|
};
|
|
|
|
|
|
|
|
// Emit failure event
|
|
|
|
this.emit(CertificateEvents.CERTIFICATE_FAILED, failure);
|
|
|
|
|
|
|
|
throw new Error(`Failed to ${isRenewal ? 'renew' : 'obtain'} certificate for ${domain}: ${
|
|
|
|
error instanceof Error ? error.message : String(error)
|
|
|
|
}`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Check if a certificate is expiring soon
|
|
|
|
* @param domain Domain name
|
|
|
|
* @param certificate Certificate data
|
|
|
|
* @param thresholdDays Days before expiry to trigger a renewal
|
|
|
|
*/
|
|
|
|
public checkCertificateExpiry(
|
|
|
|
domain: string,
|
|
|
|
certificate: CertificateData,
|
|
|
|
thresholdDays: number = 30
|
|
|
|
): void {
|
|
|
|
if (!certificate.expiryDate) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const now = new Date();
|
|
|
|
const expiryDate = certificate.expiryDate;
|
|
|
|
const daysDifference = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
|
|
|
|
|
if (daysDifference <= thresholdDays) {
|
|
|
|
const expiryInfo: CertificateExpiring = {
|
|
|
|
domain,
|
|
|
|
expiryDate,
|
|
|
|
daysRemaining: daysDifference,
|
|
|
|
};
|
|
|
|
|
|
|
|
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);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|