update structure
This commit is contained in:
221
ts/http/port80/challenge-responder.ts
Normal file
221
ts/http/port80/challenge-responder.ts
Normal file
@ -0,0 +1,221 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user