Files
onebox/ts/classes/ssl.ts

320 lines
9.3 KiB
TypeScript

/**
* 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<void> {
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 <email>');
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 <key>');
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<Date | null> {
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;
}
}
}