/** * 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 { 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 { 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 { 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 { 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 { 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 { 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 }; } }