smartproxy/ts/http/port80/challenge-responder.ts

246 lines
7.9 KiB
TypeScript

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';
import type {
SmartAcme,
SmartAcmeCert,
SmartAcmeOptions,
Http01MemoryHandler
} from './acme-interfaces.js';
/**
* ChallengeResponder handles ACME HTTP-01 challenges by leveraging SmartAcme
* It acts as a bridge between the HTTP server and the ACME challenge verification process
*/
export class ChallengeResponder extends plugins.EventEmitter {
private smartAcme: SmartAcme | null = null;
private http01Handler: 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 {
// Create the HTTP-01 memory handler from SmartACME
this.http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler();
// 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
this.smartAcme = new plugins.smartacme.SmartAcme({
accountEmail: this.email,
certManager: certManager,
environment: this.useProduction ? 'production' : 'integration',
challengeHandlers: [this.http01Handler],
challengePriority: ['http-01']
});
// Set up event forwarding from SmartAcme
this.setupEventListeners();
// Start the SmartACME client
await this.smartAcme.start();
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 });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to create certificate store: ${errorMessage}`);
}
}
/**
* Setup event listeners to forward SmartACME events to our own event emitter
*/
private setupEventListeners(): void {
if (!this.smartAcme) return;
const setupEvents = (emitter: { on: (event: string, listener: (data: any) => void) => void }) => {
// Forward certificate events
emitter.on('certificate', (data: any) => {
const isRenewal = !!data.isRenewal;
const certData: CertificateData = {
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
};
const eventType = isRenewal
? CertificateEvents.CERTIFICATE_RENEWED
: CertificateEvents.CERTIFICATE_ISSUED;
this.emit(eventType, certData);
});
// Forward error events
emitter.on('error', (error: any) => {
const domain = error.domainName || error.domain || 'unknown';
const failureData: CertificateFailure = {
domain,
error: error.message || String(error),
isRenewal: !!error.isRenewal
};
this.emit(CertificateEvents.CERTIFICATE_FAILED, failureData);
});
};
// 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');
}
}
/**
* 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
*/
public handleRequest(req: IncomingMessage, res: ServerResponse): boolean {
if (!this.http01Handler) return false;
// Check if this is an ACME challenge request (/.well-known/acme-challenge/*)
const url = req.url || '';
if (url.startsWith('/.well-known/acme-challenge/')) {
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;
}
}
return false;
}
/**
* Request a certificate for a domain
* @param domain Domain name to request a certificate for
* @param isRenewal Whether this is a renewal request
*/
public async requestCertificate(domain: string, isRenewal: boolean = false): Promise<CertificateData> {
if (!this.smartAcme) {
throw new Error('ACME client not initialized');
}
try {
// Request certificate using SmartACME
const certObj = await this.smartAcme.getCertificateForDomain(domain);
// Convert the certificate object to our CertificateData format
const certData: CertificateData = {
domain,
certificate: certObj.publicKey,
privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil),
source: 'http01',
isRenewal
};
return certData;
} catch (error) {
// Create failure object
const failure: CertificateFailure = {
domain,
error: error instanceof Error ? error.message : String(error),
isRenewal
};
// Emit failure event
this.emit(CertificateEvents.CERTIFICATE_FAILED, failure);
// Rethrow with more context
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 and trigger renewal if needed
* @param domain Domain name
* @param certificate Certificate data
* @param thresholdDays Days before expiry to trigger 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);
});
}
}
}
}