- Complete Deno-based architecture following nupst/spark patterns - SQLite database with full schema - Docker container management - Service orchestration (Docker + Nginx + DNS + SSL) - Registry authentication - Nginx reverse proxy configuration - Cloudflare DNS integration - Let's Encrypt SSL automation - Background daemon with metrics collection - HTTP API server - Comprehensive CLI - Cross-platform compilation setup - NPM distribution wrapper - Shell installer script Core features: - Deploy containers with single command - Automatic domain configuration - Automatic SSL certificates - Multi-registry support - Metrics and logging - Systemd integration Ready for Angular UI implementation and testing.
318 lines
8.5 KiB
TypeScript
318 lines
8.5 KiB
TypeScript
/**
|
|
* SSL Manager for Onebox
|
|
*
|
|
* Manages SSL certificates via Let's Encrypt (using smartacme)
|
|
*/
|
|
|
|
import * as plugins from './onebox.plugins.ts';
|
|
import { logger } from './onebox.logging.ts';
|
|
import { OneboxDatabase } from './onebox.classes.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(),
|
|
});
|
|
}
|
|
|
|
// Enable SSL in nginx config
|
|
await this.oneboxRef.nginx.enableSSL(domain);
|
|
|
|
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}`);
|
|
|
|
// Reload nginx
|
|
await this.oneboxRef.nginx.reload();
|
|
} 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');
|
|
|
|
// Reload nginx
|
|
await this.oneboxRef.nginx.reload();
|
|
} 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;
|
|
}
|
|
}
|
|
}
|