/** * SSL Manager for Onebox * * Manages SSL certificates via Let's Encrypt (using smartacme) */ import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; import { OneboxDatabase } from './database.ts'; import { SqliteCertManager } from './certmanager.ts'; export class OneboxSslManager { private oneboxRef: any; private database: OneboxDatabase; private smartacme: plugins.smartacme.SmartAcme | null = null; private certManager: SqliteCertManager | null = null; private acmeEmail: string | null = null; constructor(oneboxRef: any) { this.oneboxRef = oneboxRef; this.database = oneboxRef.database; } /** * Initialize SSL manager */ async init(): Promise { try { // Get ACME email from settings const acmeEmail = this.database.getSetting('acmeEmail'); if (!acmeEmail) { logger.warn('ACME email not configured. SSL certificate management will be limited.'); logger.info('Configure with: onebox config set acmeEmail '); return; } this.acmeEmail = acmeEmail; // Get Cloudflare API key (reuse from DNS manager) const cfApiKey = this.database.getSetting('cloudflareAPIKey'); if (!cfApiKey) { logger.warn('Cloudflare API key not configured. SSL certificate management will be limited.'); logger.info('Configure with: onebox config set cloudflareAPIKey '); return; } // Get ACME environment (default: production) const acmeEnvironment = this.database.getSetting('acmeEnvironment') || 'production'; if (!['production', 'integration'].includes(acmeEnvironment)) { throw new Error('acmeEnvironment must be "production" or "integration"'); } // Initialize certificate manager this.certManager = new SqliteCertManager(this.database); await this.certManager.init(); // Initialize Cloudflare DNS provider for DNS-01 challenge const cfAccount = new plugins.cloudflare.CloudflareAccount(cfApiKey); // Create DNS-01 challenge handler const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(cfAccount); // Initialize SmartACME with proper configuration this.smartacme = new plugins.smartacme.SmartAcme({ accountEmail: acmeEmail, certManager: this.certManager, environment: acmeEnvironment as 'production' | 'integration', challengeHandlers: [dns01Handler], challengePriority: ['dns-01'], // Prefer DNS-01 challenges }); // Start SmartACME await this.smartacme.start(); logger.success('SSL manager initialized with SmartACME DNS-01 challenge'); } catch (error) { logger.error(`Failed to initialize SSL manager: ${error.message}`); throw error; } } /** * Check if SSL manager is configured */ isConfigured(): boolean { return this.smartacme !== null && this.acmeEmail !== null; } /** * Obtain SSL certificate for a domain using SmartACME */ async obtainCertificate(domain: string, includeWildcard = false): Promise { try { if (!this.isConfigured()) { throw new Error('SSL manager not configured'); } logger.info(`Obtaining SSL certificate for ${domain} via SmartACME DNS-01...`); // Check if certificate already exists and is valid const existingCert = await this.certManager!.retrieveCertificate(domain); if (existingCert && existingCert.isStillValid()) { logger.info(`Valid certificate already exists for ${domain}`); return; } // Use SmartACME to obtain certificate via DNS-01 challenge const cert = await this.smartacme!.getCertificateForDomain(domain, { includeWildcard, }); logger.success(`SSL certificate obtained for ${domain}`); logger.info(`Certificate valid until: ${new Date(cert.validUntil).toISOString()}`); // Reload certificates in reverse proxy await this.oneboxRef.reverseProxy.reloadCertificates(); } catch (error) { logger.error(`Failed to obtain certificate for ${domain}: ${error.message}`); throw error; } } /** * Obtain certificate using certbot */ private async obtainCertificateWithCertbot(domain: string): Promise { try { logger.info(`Running certbot for ${domain}...`); // Use webroot method (nginx serves .well-known/acme-challenge) const command = new Deno.Command('certbot', { args: [ 'certonly', '--webroot', '--webroot-path=/var/www/certbot', '--email', this.acmeEmail!, '--agree-tos', '--no-eff-email', '--domain', domain, '--non-interactive', ], stdout: 'piped', stderr: 'piped', }); const { code, stderr } = await command.output(); if (code !== 0) { const errorMsg = new TextDecoder().decode(stderr); throw new Error(`Certbot failed: ${errorMsg}`); } logger.success(`Certbot obtained certificate for ${domain}`); } catch (error) { throw new Error(`Failed to run certbot: ${error.message}`); } } /** * Renew certificate for a domain using SmartACME */ async renewCertificate(domain: string): Promise { try { if (!this.isConfigured()) { throw new Error('SSL manager not configured'); } logger.info(`Renewing SSL certificate for ${domain} via SmartACME...`); // SmartACME will check if renewal is needed and obtain a new certificate const cert = await this.smartacme!.getCertificateForDomain(domain); logger.success(`Certificate renewed for ${domain}`); logger.info(`Certificate valid until: ${new Date(cert.validUntil).toISOString()}`); // Reload certificates in reverse proxy await this.oneboxRef.reverseProxy.reloadCertificates(); } catch (error) { logger.error(`Failed to renew certificate for ${domain}: ${error.message}`); throw error; } } /** * List all certificates */ listCertificates() { return this.database.getAllSSLCertificates(); } /** * Get certificate info for a domain */ getCertificate(domain: string) { return this.database.getSSLCertificate(domain); } /** * Check certificates that are expiring soon and renew them */ async renewExpiring(): Promise { try { if (!this.isConfigured()) { logger.warn('SSL manager not configured, skipping renewal check'); return; } logger.info('Checking for expiring certificates...'); const certificates = this.listCertificates(); for (const dbCert of certificates) { try { // Retrieve certificate from cert manager to check if it should be renewed const cert = await this.certManager!.retrieveCertificate(dbCert.domain); if (cert && cert.shouldBeRenewed()) { logger.info(`Certificate for ${dbCert.domain} needs renewal, renewing...`); await this.renewCertificate(dbCert.domain); } } catch (error) { logger.error(`Failed to renew ${dbCert.domain}: ${error.message}`); // Continue with other certificates } } logger.success('Certificate renewal check complete'); } catch (error) { logger.error(`Failed to check expiring certificates: ${error.message}`); throw error; } } /** * Force renewal of all certificates */ async renewAll(): Promise { try { logger.info('Renewing all certificates...'); const command = new Deno.Command('certbot', { args: ['renew', '--force-renewal', '--non-interactive'], stdout: 'piped', stderr: 'piped', }); const { code, stderr } = await command.output(); if (code !== 0) { const errorMsg = new TextDecoder().decode(stderr); throw new Error(`Certbot renewal failed: ${errorMsg}`); } logger.success('All certificates renewed'); // Reload certificates in reverse proxy await this.oneboxRef.reverseProxy.reloadCertificates(); } catch (error) { logger.error(`Failed to renew all certificates: ${error.message}`); throw error; } } /** * Check if certbot is installed */ async isCertbotInstalled(): Promise { try { const command = new Deno.Command('which', { args: ['certbot'], stdout: 'piped', stderr: 'piped', }); const { code } = await command.output(); return code === 0; } catch { return false; } } /** * Get certificate expiry date from file */ async getCertificateExpiry(domain: string): Promise { try { const certPath = `/etc/letsencrypt/live/${domain}/cert.pem`; const command = new Deno.Command('openssl', { args: ['x509', '-enddate', '-noout', '-in', certPath], stdout: 'piped', stderr: 'piped', }); const { code, stdout } = await command.output(); if (code !== 0) { return null; } const output = new TextDecoder().decode(stdout); const match = output.match(/notAfter=(.+)/); if (match) { return new Date(match[1]); } return null; } catch (error) { logger.error(`Failed to get certificate expiry for ${domain}: ${error.message}`); return null; } } }