Files
onebox/ts/classes/ssl.ts

318 lines
8.6 KiB
TypeScript
Raw Normal View History

/**
* SSL Manager for Onebox
*
* Manages SSL certificates via Let's Encrypt (using smartacme)
*/
2025-11-18 00:03:24 +00:00
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { OneboxDatabase } from './database.ts';
export class OneboxSslManager {
private oneboxRef: any;
private database: OneboxDatabase;
private smartacme: plugins.smartacme.SmartAcme | 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;
// Initialize SmartACME
this.smartacme = new plugins.smartacme.SmartAcme({
email: acmeEmail,
environment: 'production', // or 'staging' for testing
dns: 'cloudflare', // Use Cloudflare DNS challenge
});
logger.info('SSL manager initialized with SmartACME');
} 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
*/
async obtainCertificate(domain: string): Promise<void> {
try {
if (!this.isConfigured()) {
throw new Error('SSL manager not configured');
}
logger.info(`Obtaining SSL certificate for ${domain}...`);
// Check if certificate already exists and is valid
const existing = this.database.getSSLCertificate(domain);
if (existing && existing.expiryDate > Date.now()) {
logger.info(`Valid certificate already exists for ${domain}`);
return;
}
// Use certbot for now (smartacme integration would be more complex)
// This is a simplified version - in production, use proper ACME client
await this.obtainCertificateWithCertbot(domain);
// Store in database
const certPath = `/etc/letsencrypt/live/${domain}/cert.pem`;
const keyPath = `/etc/letsencrypt/live/${domain}/privkey.pem`;
const fullChainPath = `/etc/letsencrypt/live/${domain}/fullchain.pem`;
// Get expiry date (90 days from now for Let's Encrypt)
const expiryDate = Date.now() + 90 * 24 * 60 * 60 * 1000;
if (existing) {
this.database.updateSSLCertificate(domain, {
certPath,
keyPath,
fullChainPath,
expiryDate,
});
} else {
await this.database.createSSLCertificate({
domain,
certPath,
keyPath,
fullChainPath,
expiryDate,
issuer: 'Let\'s Encrypt',
createdAt: Date.now(),
updatedAt: Date.now(),
});
}
2025-11-18 00:03:24 +00:00
// Reload certificates in reverse proxy
await this.oneboxRef.reverseProxy.reloadCertificates();
logger.success(`SSL certificate obtained for ${domain}`);
} 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
*/
async renewCertificate(domain: string): Promise<void> {
try {
logger.info(`Renewing SSL certificate for ${domain}...`);
const command = new Deno.Command('certbot', {
args: ['renew', '--cert-name', 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 renewal failed: ${errorMsg}`);
}
// Update database
const expiryDate = Date.now() + 90 * 24 * 60 * 60 * 1000;
this.database.updateSSLCertificate(domain, {
expiryDate,
});
logger.success(`Certificate renewed for ${domain}`);
2025-11-18 00:03:24 +00:00
// 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 {
logger.info('Checking for expiring certificates...');
const certificates = this.listCertificates();
const thirtyDaysFromNow = Date.now() + 30 * 24 * 60 * 60 * 1000;
for (const cert of certificates) {
if (cert.expiryDate < thirtyDaysFromNow) {
logger.info(`Certificate for ${cert.domain} expires soon, renewing...`);
try {
await this.renewCertificate(cert.domain);
} catch (error) {
logger.error(`Failed to renew ${cert.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');
2025-11-18 00:03:24 +00:00
// 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;
}
}
}