2025-10-28 13:05:42 +00:00
|
|
|
/**
|
|
|
|
|
* 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';
|
2025-11-18 19:34:26 +00:00
|
|
|
import { SqliteCertManager } from './certmanager.ts';
|
2025-10-28 13:05:42 +00:00
|
|
|
|
|
|
|
|
export class OneboxSslManager {
|
|
|
|
|
private oneboxRef: any;
|
|
|
|
|
private database: OneboxDatabase;
|
|
|
|
|
private smartacme: plugins.smartacme.SmartAcme | null = null;
|
2025-11-18 19:34:26 +00:00
|
|
|
private certManager: SqliteCertManager | null = null;
|
2025-10-28 13:05:42 +00:00
|
|
|
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;
|
|
|
|
|
|
2025-11-18 19:34:26 +00:00
|
|
|
// 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
|
2025-10-28 13:05:42 +00:00
|
|
|
this.smartacme = new plugins.smartacme.SmartAcme({
|
2025-11-18 19:34:26 +00:00
|
|
|
accountEmail: acmeEmail,
|
|
|
|
|
certManager: this.certManager,
|
|
|
|
|
environment: acmeEnvironment as 'production' | 'integration',
|
|
|
|
|
challengeHandlers: [dns01Handler],
|
|
|
|
|
challengePriority: ['dns-01'], // Prefer DNS-01 challenges
|
2025-10-28 13:05:42 +00:00
|
|
|
});
|
|
|
|
|
|
2025-11-18 19:34:26 +00:00
|
|
|
// Start SmartACME
|
|
|
|
|
await this.smartacme.start();
|
|
|
|
|
|
|
|
|
|
logger.success('SSL manager initialized with SmartACME DNS-01 challenge');
|
2025-10-28 13:05:42 +00:00
|
|
|
} 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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 19:36:08 +00:00
|
|
|
/**
|
|
|
|
|
* Acquire certificate and return certificate data (for CertRequirementManager)
|
|
|
|
|
* Returns certificate paths and expiry information
|
|
|
|
|
*/
|
|
|
|
|
async acquireCertificate(
|
|
|
|
|
domain: string,
|
|
|
|
|
includeWildcard = false
|
|
|
|
|
): Promise<{
|
|
|
|
|
certPath: string;
|
|
|
|
|
keyPath: string;
|
|
|
|
|
fullChainPath: string;
|
|
|
|
|
expiryDate: number;
|
|
|
|
|
issuer: string;
|
|
|
|
|
}> {
|
|
|
|
|
try {
|
|
|
|
|
if (!this.isConfigured()) {
|
|
|
|
|
throw new Error('SSL manager not configured');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info(`Acquiring SSL certificate for ${domain} via SmartACME DNS-01...`);
|
|
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
|
|
|
|
// Return certificate data
|
|
|
|
|
return {
|
|
|
|
|
certPath: cert.certFilePath,
|
|
|
|
|
keyPath: cert.keyFilePath,
|
|
|
|
|
fullChainPath: cert.chainFilePath || cert.certFilePath,
|
|
|
|
|
expiryDate: cert.validUntil,
|
|
|
|
|
issuer: cert.issuer || 'Let\'s Encrypt',
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Failed to acquire certificate for ${domain}: ${error.message}`);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-28 13:05:42 +00:00
|
|
|
/**
|
2025-11-18 19:34:26 +00:00
|
|
|
* Obtain SSL certificate for a domain using SmartACME
|
2025-10-28 13:05:42 +00:00
|
|
|
*/
|
2025-11-18 19:34:26 +00:00
|
|
|
async obtainCertificate(domain: string, includeWildcard = false): Promise<void> {
|
2025-10-28 13:05:42 +00:00
|
|
|
try {
|
|
|
|
|
if (!this.isConfigured()) {
|
|
|
|
|
throw new Error('SSL manager not configured');
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 19:34:26 +00:00
|
|
|
logger.info(`Obtaining SSL certificate for ${domain} via SmartACME DNS-01...`);
|
2025-10-28 13:05:42 +00:00
|
|
|
|
|
|
|
|
// Check if certificate already exists and is valid
|
2025-11-18 19:34:26 +00:00
|
|
|
const existingCert = await this.certManager!.retrieveCertificate(domain);
|
|
|
|
|
if (existingCert && existingCert.isStillValid()) {
|
2025-10-28 13:05:42 +00:00
|
|
|
logger.info(`Valid certificate already exists for ${domain}`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 19:34:26 +00:00
|
|
|
// 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()}`);
|
2025-10-28 13:05:42 +00:00
|
|
|
|
2025-11-18 00:03:24 +00:00
|
|
|
// Reload certificates in reverse proxy
|
|
|
|
|
await this.oneboxRef.reverseProxy.reloadCertificates();
|
2025-10-28 13:05:42 +00:00
|
|
|
} 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}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-11-18 19:34:26 +00:00
|
|
|
* Renew certificate for a domain using SmartACME
|
2025-10-28 13:05:42 +00:00
|
|
|
*/
|
|
|
|
|
async renewCertificate(domain: string): Promise<void> {
|
|
|
|
|
try {
|
2025-11-18 19:34:26 +00:00
|
|
|
if (!this.isConfigured()) {
|
|
|
|
|
throw new Error('SSL manager not configured');
|
2025-10-28 13:05:42 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-18 19:34:26 +00:00
|
|
|
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);
|
2025-10-28 13:05:42 +00:00
|
|
|
|
|
|
|
|
logger.success(`Certificate renewed for ${domain}`);
|
2025-11-18 19:34:26 +00:00
|
|
|
logger.info(`Certificate valid until: ${new Date(cert.validUntil).toISOString()}`);
|
2025-10-28 13:05:42 +00:00
|
|
|
|
2025-11-18 00:03:24 +00:00
|
|
|
// Reload certificates in reverse proxy
|
|
|
|
|
await this.oneboxRef.reverseProxy.reloadCertificates();
|
2025-10-28 13:05:42 +00:00
|
|
|
} 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 {
|
2025-11-18 19:34:26 +00:00
|
|
|
if (!this.isConfigured()) {
|
|
|
|
|
logger.warn('SSL manager not configured, skipping renewal check');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-28 13:05:42 +00:00
|
|
|
logger.info('Checking for expiring certificates...');
|
|
|
|
|
|
|
|
|
|
const certificates = this.listCertificates();
|
|
|
|
|
|
2025-11-18 19:34:26 +00:00
|
|
|
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);
|
2025-10-28 13:05:42 +00:00
|
|
|
|
2025-11-18 19:34:26 +00:00
|
|
|
if (cert && cert.shouldBeRenewed()) {
|
|
|
|
|
logger.info(`Certificate for ${dbCert.domain} needs renewal, renewing...`);
|
|
|
|
|
await this.renewCertificate(dbCert.domain);
|
2025-10-28 13:05:42 +00:00
|
|
|
}
|
2025-11-18 19:34:26 +00:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Failed to renew ${dbCert.domain}: ${error.message}`);
|
|
|
|
|
// Continue with other certificates
|
2025-10-28 13:05:42 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
2025-10-28 13:05:42 +00:00
|
|
|
} 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|