smartproxy/ts/http/port80/challenge-responder.ts
2025-05-09 17:00:27 +00:00

221 lines
6.8 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';
/**
* 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 SmartAcme
this.smartAcme = new plugins.smartacme.SmartAcme({
useProduction: this.useProduction,
accountEmail: this.email,
directoryUrl: this.useProduction
? 'https://acme-v02.api.letsencrypt.org/directory' // Production
: 'https://acme-staging-v02.api.letsencrypt.org/directory', // Staging
});
// Initialize HTTP-01 challenge handler
this.http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler();
this.smartAcme.useHttpChallenge(this.http01Handler);
// Ensure certificate store directory exists
await this.ensureCertificateStore();
// Subscribe to SmartAcme events
this.smartAcme.on('certificate-issued', (data: any) => {
const certData: CertificateData = {
domain: data.domain,
certificate: data.cert,
privateKey: data.key,
expiryDate: new Date(data.expiryDate),
};
this.emit(CertificateEvents.CERTIFICATE_ISSUED, certData);
});
this.smartAcme.on('certificate-renewed', (data: any) => {
const certData: CertificateData = {
domain: data.domain,
certificate: data.cert,
privateKey: data.key,
expiryDate: new Date(data.expiryDate),
};
this.emit(CertificateEvents.CERTIFICATE_RENEWED, certData);
});
this.smartAcme.on('certificate-error', (data: any) => {
const error: CertificateFailure = {
domain: data.domain,
error: data.error instanceof Error ? data.error.message : String(data.error),
isRenewal: data.isRenewal || false,
};
this.emit(CertificateEvents.CERTIFICATE_FAILED, error);
});
await this.smartAcme.initialize();
} catch (error) {
throw new Error(`Failed to initialize ACME client: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* 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 || '/';
// Check if this is an ACME challenge request
if (url.startsWith('/.well-known/acme-challenge/')) {
const token = url.split('/').pop() || '';
if (token) {
const response = this.http01Handler.getResponse(token);
if (response) {
// This is a valid ACME challenge
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
res.writeHead(200);
res.end(response);
return true;
}
}
// Invalid ACME challenge
res.writeHead(404);
res.end('Not found');
return true;
}
return false;
}
/**
* 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 {
const result = await this.smartAcme.getCertificate(domain);
const certData: CertificateData = {
domain,
certificate: result.cert,
privateKey: result.key,
expiryDate: new Date(result.expiryDate),
};
// Emit appropriate event
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);
});
}
}
}
}