Files
onebox/ts/classes/cert-requirement-manager.ts

340 lines
10 KiB
TypeScript
Raw Normal View History

/**
* Certificate Requirement Manager
*
* Manages the lifecycle of SSL certificates based on service requirements.
* Automatically acquires, renews, and cleans up certificates.
*/
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import { OneboxDatabase } from './database.ts';
import { OneboxSslManager } from './ssl.ts';
import type { ICertRequirement, ICertificate, IDomain } from '../types.ts';
export class CertRequirementManager {
private database: OneboxDatabase;
private sslManager: OneboxSslManager;
// Certificate renewal threshold (30 days before expiry)
private readonly RENEWAL_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
// Certificate cleanup delay (90 days after becoming invalid)
private readonly CLEANUP_DELAY_MS = 90 * 24 * 60 * 60 * 1000;
constructor(database: OneboxDatabase, sslManager: OneboxSslManager) {
this.database = database;
this.sslManager = sslManager;
}
/**
* Process all pending certificate requirements
* Matches requirements to existing certificates or schedules acquisition
*/
async processPendingRequirements(): Promise<void> {
try {
const allRequirements = this.database.getAllCertRequirements();
const pendingRequirements = allRequirements.filter((req) => req.status === 'pending');
logger.info(`Processing ${pendingRequirements.length} pending certificate requirement(s)`);
for (const requirement of pendingRequirements) {
try {
await this.processRequirement(requirement);
} catch (error) {
logger.error(
`Failed to process requirement ${requirement.id}: ${getErrorMessage(error)}`
);
}
}
} catch (error) {
logger.error(`Failed to process pending requirements: ${getErrorMessage(error)}`);
}
}
/**
* Process a single certificate requirement
*/
private async processRequirement(requirement: ICertRequirement): Promise<void> {
const domain = this.database.getDomainById(requirement.domainId);
if (!domain) {
logger.error(`Domain ${requirement.domainId} not found for requirement ${requirement.id}`);
return;
}
// Construct the full domain name
const fullDomain = requirement.subdomain
? `${requirement.subdomain}.${domain.domain}`
: domain.domain;
logger.debug(`Processing requirement for domain: ${fullDomain}`);
// Check if a valid certificate already exists
const existingCert = this.findValidCertificate(domain, requirement.subdomain);
if (existingCert) {
// Link existing certificate to requirement
this.database.updateCertRequirement(requirement.id!, {
certificateId: existingCert.id,
status: 'active',
});
logger.info(`Linked existing certificate to requirement for ${fullDomain}`);
} else {
// Schedule certificate acquisition
await this.acquireCertificate(requirement, domain, fullDomain);
}
}
/**
* Find a valid certificate for the given domain and subdomain
*/
private findValidCertificate(
domain: IDomain,
subdomain: string
): ICertificate | null {
const certificates = this.database.getCertificatesByDomain(domain.id!);
const now = Date.now();
for (const cert of certificates) {
// Skip invalid or expired certificates
if (!cert.isValid || cert.expiryDate <= now) {
continue;
}
// Check if certificate covers the required domain
if (cert.isWildcard && !subdomain) {
// Wildcard cert covers base domain
return cert;
} else if (cert.isWildcard && subdomain) {
// Wildcard cert covers first-level subdomains
const levels = subdomain.split('.').length;
if (levels === 1) {
return cert;
}
} else if (!cert.isWildcard && cert.certDomain === domain.domain && !subdomain) {
// Exact match for base domain
return cert;
} else if (!cert.isWildcard && subdomain) {
// Exact match for specific subdomain
const fullDomain = `${subdomain}.${domain.domain}`;
if (cert.certDomain === fullDomain) {
return cert;
}
}
}
return null;
}
/**
* Acquire a new certificate for the requirement
*/
private async acquireCertificate(
requirement: ICertRequirement,
domain: IDomain,
fullDomain: string
): Promise<void> {
try {
logger.info(`Acquiring certificate for ${fullDomain}...`);
// Determine if we should use wildcard
const useWildcard = domain.defaultWildcard && !requirement.subdomain;
// Acquire certificate using SSL manager
const certData = await this.sslManager.acquireCertificate(
domain.domain,
useWildcard
);
// Store certificate in database
const now = Date.now();
const certificate = this.database.createCertificate({
domainId: domain.id!,
certDomain: domain.domain,
isWildcard: useWildcard,
certPath: certData.certPath,
keyPath: certData.keyPath,
fullChainPath: certData.fullChainPath,
expiryDate: certData.expiryDate,
issuer: certData.issuer,
isValid: true,
createdAt: now,
updatedAt: now,
});
// Link certificate to requirement
this.database.updateCertRequirement(requirement.id!, {
certificateId: certificate.id,
status: 'active',
});
logger.success(`Certificate acquired for ${fullDomain}`);
} catch (error) {
logger.error(`Failed to acquire certificate for ${fullDomain}: ${getErrorMessage(error)}`);
throw error;
}
}
/**
* Check all certificates for renewal
*/
async checkCertificateRenewal(): Promise<void> {
try {
const allCertificates = this.database.getAllCertificates();
const now = Date.now();
for (const cert of allCertificates) {
// Skip invalid certificates
if (!cert.isValid) {
continue;
}
// Check if certificate is expired
if (cert.expiryDate <= now) {
// Mark as invalid
this.database.updateCertificate(cert.id!, { isValid: false });
logger.warn(`Certificate ${cert.id} for ${cert.certDomain} has expired`);
continue;
}
// Check if certificate needs renewal
const timeUntilExpiry = cert.expiryDate - now;
if (timeUntilExpiry <= this.RENEWAL_THRESHOLD_MS) {
await this.renewCertificate(cert);
}
}
} catch (error) {
logger.error(`Failed to check certificate renewal: ${getErrorMessage(error)}`);
}
}
/**
* Renew a certificate
*/
private async renewCertificate(cert: ICertificate): Promise<void> {
try {
logger.info(`Renewing certificate for ${cert.certDomain}...`);
const domain = this.database.getDomainById(cert.domainId);
if (!domain) {
logger.error(`Domain ${cert.domainId} not found for certificate ${cert.id}`);
return;
}
// Mark all requirements using this certificate as renewing
const requirements = this.database.getAllCertRequirements();
const relatedRequirements = requirements.filter(
(req) => req.certificateId === cert.id
);
for (const req of relatedRequirements) {
this.database.updateCertRequirement(req.id!, { status: 'renewing' });
}
// Acquire new certificate
const certData = await this.sslManager.acquireCertificate(
domain.domain,
cert.isWildcard
);
// Update certificate in database
this.database.updateCertificate(cert.id!, {
certPath: certData.certPath,
keyPath: certData.keyPath,
fullChainPath: certData.fullChainPath,
expiryDate: certData.expiryDate,
issuer: certData.issuer,
});
// Mark requirements as active again
for (const req of relatedRequirements) {
this.database.updateCertRequirement(req.id!, { status: 'active' });
}
logger.success(`Certificate renewed for ${cert.certDomain}`);
} catch (error) {
logger.error(`Failed to renew certificate for ${cert.certDomain}: ${getErrorMessage(error)}`);
}
}
/**
* Clean up old invalid certificates (90+ days old)
*/
async cleanupOldCertificates(): Promise<void> {
try {
const allCertificates = this.database.getAllCertificates();
const now = Date.now();
let deletedCount = 0;
for (const cert of allCertificates) {
// Only clean up invalid certificates
if (!cert.isValid) {
// Check if certificate has been invalid for 90+ days
const timeSinceExpiry = now - cert.expiryDate;
if (timeSinceExpiry >= this.CLEANUP_DELAY_MS) {
// Delete certificate files
try {
await Deno.remove(cert.certPath);
await Deno.remove(cert.keyPath);
await Deno.remove(cert.fullChainPath);
} catch (error) {
logger.debug(
`Failed to delete certificate files for ${cert.certDomain}: ${getErrorMessage(error)}`
);
}
// Delete from database
this.database.deleteCertificate(cert.id!);
deletedCount++;
logger.info(
`Deleted old certificate ${cert.id} for ${cert.certDomain} (expired ${new Date(
cert.expiryDate
).toISOString()})`
);
}
}
}
if (deletedCount > 0) {
logger.info(`Cleaned up ${deletedCount} old certificate(s)`);
}
} catch (error) {
logger.error(`Failed to cleanup old certificates: ${getErrorMessage(error)}`);
}
}
/**
* Get certificate status for a domain
*/
getCertificateStatus(domainId: number): {
valid: number;
expiringSoon: number;
expired: number;
pending: number;
} {
const certificates = this.database.getCertificatesByDomain(domainId);
const requirements = this.database.getCertRequirementsByDomain(domainId);
const now = Date.now();
let valid = 0;
let expiringSoon = 0;
let expired = 0;
for (const cert of certificates) {
if (!cert.isValid) {
expired++;
} else if (cert.expiryDate <= now) {
expired++;
} else if (cert.expiryDate - now <= this.RENEWAL_THRESHOLD_MS) {
expiringSoon++;
} else {
valid++;
}
}
const pending = requirements.filter((req) => req.status === 'pending').length;
return { valid, expiringSoon, expired, pending };
}
}