221 lines
6.8 KiB
TypeScript
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);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} |