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

311 lines
10 KiB
TypeScript
Raw Normal View History

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 {
// 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,
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();
// 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,
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
};
// 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
});
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,
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
};
const eventType = data.isRenewal
? CertificateEvents.CERTIFICATE_RENEWED
: CertificateEvents.CERTIFICATE_ISSUED;
this.emit(eventType, certData);
2025-05-09 17:00:27 +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
};
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:00:27 +00:00
// Check if this is an ACME challenge request
if (url.startsWith('/.well-known/acme-challenge/')) {
const token = url.split('/').pop() || '';
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:00:27 +00:00
// Invalid ACME challenge
res.writeHead(404);
res.end('Not found');
return true;
}
2025-05-09 17:00:27 +00:00
return false;
}
/**
* 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 {
// Request certificate via SmartAcme
const certObj = await this.smartAcme.getCertificateForDomain(domain);
2025-05-09 17:00:27 +00:00
const certData: CertificateData = {
domain,
certificate: certObj.publicKey,
privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil),
source: 'http01',
isRenewal
2025-05-09 17:00:27 +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);
});
}
}
}
}