platform-tooling/scripts/orchestration/ssl-manager.ts
2026-03-02 21:06:54 -08:00

711 lines
18 KiB
JavaScript
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* SSL Certificate Manager for Production
* Manages Let's Encrypt SSL certificates for all production domains
*
* Features:
* - Certificate existence checks
* - Expiration monitoring (7-day warning threshold)
* - Automated certificate requests via certbot
* - HTTP-01 challenge support (individual domains)
* - DNS-01 challenge support (wildcard domains, if configured)
* - Certificate validation
* - Renewal automation
*
* Usage:
* pnpm tsx tooling/scripts/orchestration/ssl-manager.ts check
* pnpm tsx tooling/scripts/orchestration/ssl-manager.ts request atlilith.com
* pnpm tsx tooling/scripts/orchestration/ssl-manager.ts renew
*/
import { execFileSync, spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
// ============================================================================
// Type Definitions
// ============================================================================
export interface CertificateStatus {
domain: string;
exists: boolean;
valid: boolean;
expiresAt?: Date;
daysUntilExpiry?: number;
certPath?: string;
keyPath?: string;
chainPath?: string;
fullchainPath?: string;
error?: string;
}
export interface RenewalResult {
attempted: number;
succeeded: string[];
failed: Array<{
domain: string;
error: string;
}>;
skipped: string[];
}
export interface DomainConfig {
domain: string;
aliases?: string[];
challengeType: 'http-01' | 'dns-01';
webroot?: string;
email: string;
}
// ============================================================================
// Configuration
// ============================================================================
/**
* Production domains to manage
*/
const PRODUCTION_DOMAINS: DomainConfig[] = [
// Landing (atlilith.com)
{
domain: 'atlilith.com',
aliases: ['www.atlilith.com'],
challengeType: 'http-01',
webroot: '/var/www/certbot',
email: 'admin@atlilith.com',
},
// SSO
{
domain: 'sso.atlilith.com',
challengeType: 'http-01',
webroot: '/var/www/certbot',
email: 'admin@atlilith.com',
},
// Admin
{
domain: 'admin.atlilith.com',
challengeType: 'http-01',
webroot: '/var/www/certbot',
email: 'admin@atlilith.com',
},
// Marketplace (trustedmeet.com)
{
domain: 'trustedmeet.com',
aliases: ['www.trustedmeet.com'],
challengeType: 'http-01',
webroot: '/var/www/certbot',
email: 'admin@atlilith.com',
},
// SEO
{
domain: 'seo.atlilith.com',
challengeType: 'http-01',
webroot: '/var/www/certbot',
email: 'admin@atlilith.com',
},
// Analytics
{
domain: 'analytics.atlilith.com',
challengeType: 'http-01',
webroot: '/var/www/certbot',
email: 'admin@atlilith.com',
},
// Profile
{
domain: 'profile.atlilith.com',
challengeType: 'http-01',
webroot: '/var/www/certbot',
email: 'admin@atlilith.com',
},
// Status Dashboard
{
domain: 'status.atlilith.com',
challengeType: 'http-01',
webroot: '/var/www/certbot',
email: 'admin@atlilith.com',
},
];
/**
* Let's Encrypt paths (standard locations)
*/
const LETSENCRYPT_BASE = '/etc/letsencrypt';
const LETSENCRYPT_LIVE = path.join(LETSENCRYPT_BASE, 'live');
const LETSENCRYPT_ARCHIVE = path.join(LETSENCRYPT_BASE, 'archive');
const LETSENCRYPT_RENEWAL = path.join(LETSENCRYPT_BASE, 'renewal');
/**
* Certificate expiration warning threshold (days)
*/
const EXPIRY_WARNING_DAYS = 7;
/**
* Certbot command paths (search common locations)
*/
const CERTBOT_PATHS = [
'/usr/bin/certbot',
'/usr/local/bin/certbot',
'/snap/bin/certbot',
];
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Validate domain name format (prevent injection attacks)
*/
function validateDomainName(domain: string): boolean {
// RFC 1035 compliant domain validation
const domainRegex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i;
return domainRegex.test(domain);
}
/**
* Find certbot executable
*/
function findCertbot(): string | null {
for (const certbotPath of CERTBOT_PATHS) {
if (fs.existsSync(certbotPath)) {
return certbotPath;
}
}
return null;
}
/**
* Check if running as root (required for certbot)
*/
function isRoot(): boolean {
return process.getuid ? process.getuid() === 0 : false;
}
/**
* Parse certificate file to extract expiration date
*/
function parseCertificateExpiry(certPath: string): Date | null {
try {
const certPem = fs.readFileSync(certPath, 'utf8');
const certMatch = certPem.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/);
if (!certMatch) {
return null;
}
// Use openssl to parse certificate
const result = spawnSync('openssl', ['x509', '-enddate', '-noout', '-in', certPath], {
encoding: 'utf8',
});
if (result.status !== 0 || !result.stdout) {
return null;
}
// Parse: notAfter=Jan 19 12:34:56 2026 GMT
const match = result.stdout.match(/notAfter=(.+)/);
if (!match) {
return null;
}
return new Date(match[1]!);
} catch (error) {
return null;
}
}
/**
* Get certificate paths for a domain
*/
function getCertificatePaths(domain: string): {
certPath: string;
keyPath: string;
chainPath: string;
fullchainPath: string;
} {
const domainDir = path.join(LETSENCRYPT_LIVE, domain);
return {
certPath: path.join(domainDir, 'cert.pem'),
keyPath: path.join(domainDir, 'privkey.pem'),
chainPath: path.join(domainDir, 'chain.pem'),
fullchainPath: path.join(domainDir, 'fullchain.pem'),
};
}
// ============================================================================
// Core Functions
// ============================================================================
/**
* Check certificate status for a single domain
*/
export function checkCertificate(domain: string): CertificateStatus {
if (!validateDomainName(domain)) {
return {
domain,
exists: false,
valid: false,
error: 'Invalid domain name format',
};
}
const paths = getCertificatePaths(domain);
// Check if certificate files exist
const exists = fs.existsSync(paths.fullchainPath) && fs.existsSync(paths.keyPath);
if (!exists) {
return {
domain,
exists: false,
valid: false,
error: 'Certificate files not found',
};
}
// Parse expiration date
const expiresAt = parseCertificateExpiry(paths.certPath);
if (!expiresAt) {
return {
domain,
exists: true,
valid: false,
error: 'Failed to parse certificate expiration',
};
}
// Calculate days until expiry
const now = new Date();
const daysUntilExpiry = Math.floor((expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
// Certificate is valid if it exists and hasn't expired
const valid = expiresAt > now;
return {
domain,
exists: true,
valid,
expiresAt,
daysUntilExpiry,
...paths,
};
}
/**
* Check all configured certificates
*/
export async function checkCertificates(): Promise<CertificateStatus[]> {
const results: CertificateStatus[] = [];
for (const config of PRODUCTION_DOMAINS) {
const status = checkCertificate(config.domain);
results.push(status);
// Also check aliases
if (config.aliases) {
for (const alias of config.aliases) {
// For aliases, we just verify the main domain cert covers them
// Let's Encrypt stores certs by primary domain
results.push({
...status,
domain: alias,
});
}
}
}
return results;
}
/**
* Request a new certificate for a domain
*/
export async function requestCertificate(domain: string): Promise<boolean> {
// Validate domain
if (!validateDomainName(domain)) {
throw new Error(`Invalid domain name: ${domain}`);
}
// Find domain config
const config = PRODUCTION_DOMAINS.find(
c => c.domain === domain || c.aliases?.includes(domain),
);
if (!config) {
throw new Error(`Domain not in configuration: ${domain}`);
}
// Check if running as root
if (!isRoot()) {
throw new Error('Certificate requests must be run as root (sudo)');
}
// Find certbot
const certbot = findCertbot();
if (!certbot) {
throw new Error('certbot not found. Install with: sudo apt install certbot');
}
// Build certbot command arguments
const args: string[] = ['certonly', '--non-interactive', '--agree-tos'];
// Add email
args.push('--email', config.email);
// Add challenge type
if (config.challengeType === 'http-01') {
args.push('--webroot', '--webroot-path', config.webroot || '/var/www/certbot');
} else if (config.challengeType === 'dns-01') {
// DNS-01 requires additional configuration
// This would need a DNS plugin (e.g., cloudflare, route53)
throw new Error('DNS-01 challenge not yet implemented. Use HTTP-01 for now.');
}
// Add domain and aliases
args.push('-d', config.domain);
if (config.aliases) {
for (const alias of config.aliases) {
args.push('-d', alias);
}
}
// Ensure webroot exists
if (config.webroot && !fs.existsSync(config.webroot)) {
fs.mkdirSync(config.webroot, { recursive: true });
}
try {
// Execute certbot (with explicit environment to prevent shell injection)
const result = spawnSync(certbot, args, {
stdio: 'inherit',
env: {
...process.env,
PATH: process.env.PATH || '/usr/bin:/bin',
},
});
if (result.status !== 0) {
throw new Error(`certbot exited with code ${result.status}`);
}
// Verify certificate was created
const status = checkCertificate(config.domain);
if (!status.exists || !status.valid) {
throw new Error('Certificate created but validation failed');
}
return true;
} catch (error) {
throw new Error(`Certificate request failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Renew certificates that are expiring soon
*/
export async function renewCertificates(): Promise<RenewalResult> {
const result: RenewalResult = {
attempted: 0,
succeeded: [],
failed: [],
skipped: [],
};
// Check if running as root
if (!isRoot()) {
throw new Error('Certificate renewal must be run as root (sudo)');
}
// Find certbot
const certbot = findCertbot();
if (!certbot) {
throw new Error('certbot not found. Install with: sudo apt install certbot');
}
// Get current certificate statuses
const statuses = await checkCertificates();
// Group by primary domain (skip aliases)
const primaryDomains = new Set(PRODUCTION_DOMAINS.map(c => c.domain));
for (const status of statuses) {
// Only process primary domains
if (!primaryDomains.has(status.domain)) {
continue;
}
// Skip if certificate doesn't exist (needs initial request, not renewal)
if (!status.exists) {
result.skipped.push(status.domain);
continue;
}
// Skip if not expiring soon
if (status.daysUntilExpiry !== undefined && status.daysUntilExpiry > EXPIRY_WARNING_DAYS) {
result.skipped.push(status.domain);
continue;
}
// Attempt renewal
result.attempted++;
try {
// Use certbot renew with specific cert
const renewResult = spawnSync(
certbot,
['renew', '--cert-name', status.domain, '--non-interactive'],
{
stdio: 'inherit',
env: {
...process.env,
PATH: process.env.PATH || '/usr/bin:/bin',
},
},
);
if (renewResult.status !== 0) {
throw new Error(`certbot renew exited with code ${renewResult.status}`);
}
// Verify renewal
const newStatus = checkCertificate(status.domain);
if (!newStatus.valid) {
throw new Error('Renewal completed but certificate is invalid');
}
result.succeeded.push(status.domain);
} catch (error) {
result.failed.push({
domain: status.domain,
error: error instanceof Error ? error.message : String(error),
});
}
}
return result;
}
/**
* Get certificate path for nginx configuration
* Returns paths even if cert doesn't exist (for initial setup)
*/
export function getCertificatePath(domain: string): {
certPath: string;
keyPath: string;
fullchainPath: string;
} {
if (!validateDomainName(domain)) {
throw new Error(`Invalid domain name: ${domain}`);
}
const paths = getCertificatePaths(domain);
return {
certPath: paths.certPath,
keyPath: paths.keyPath,
fullchainPath: paths.fullchainPath,
};
}
/**
* Validate certificate installation (for use in deployment scripts)
*/
export async function validateCertificates(): Promise<{
valid: boolean;
errors: string[];
}> {
const errors: string[] = [];
const statuses = await checkCertificates();
// Group by primary domain
const primaryDomains = new Set(PRODUCTION_DOMAINS.map(c => c.domain));
for (const status of statuses) {
// Only validate primary domains
if (!primaryDomains.has(status.domain)) {
continue;
}
if (!status.exists) {
errors.push(`Certificate missing for ${status.domain}`);
} else if (!status.valid) {
errors.push(`Certificate invalid for ${status.domain}: ${status.error || 'expired'}`);
} else if (status.daysUntilExpiry !== undefined && status.daysUntilExpiry <= EXPIRY_WARNING_DAYS) {
errors.push(
`Certificate for ${status.domain} expires in ${status.daysUntilExpiry} days`,
);
}
}
return {
valid: errors.length === 0,
errors,
};
}
// ============================================================================
// CLI Interface
// ============================================================================
async function main() {
const command = process.argv[2];
const arg = process.argv[3];
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(' SSL Certificate Manager');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
try {
switch (command) {
case 'check': {
const statuses = await checkCertificates();
console.log('Certificate Status:\n');
// Group by primary domain
const primaryDomains = new Set(PRODUCTION_DOMAINS.map(c => c.domain));
for (const status of statuses) {
// Only show primary domains
if (!primaryDomains.has(status.domain)) {
continue;
}
const icon = status.valid ? '✓' : '✗';
const color = status.valid ? '\x1b[32m' : '\x1b[31m';
const reset = '\x1b[0m';
console.log(`${color}${icon}${reset} ${status.domain}`);
if (status.exists) {
console.log(` Expires: ${status.expiresAt?.toISOString()}`);
console.log(` Days remaining: ${status.daysUntilExpiry}`);
if (status.daysUntilExpiry !== undefined && status.daysUntilExpiry <= EXPIRY_WARNING_DAYS) {
console.log(` ⚠️ WARNING: Certificate expires soon!`);
}
} else {
console.log(` ${status.error || 'Not found'}`);
}
console.log('');
}
break;
}
case 'request': {
if (!arg) {
console.error('❌ Error: Domain required');
console.log('\nUsage: ssl-manager.ts request <domain>');
console.log('\nAvailable domains:');
for (const config of PRODUCTION_DOMAINS) {
console.log(` - ${config.domain}`);
}
process.exit(1);
}
console.log(`Requesting certificate for ${arg}...\n`);
const success = await requestCertificate(arg);
if (success) {
console.log(`\n✓ Certificate successfully obtained for ${arg}`);
}
break;
}
case 'renew': {
console.log('Checking for certificates to renew...\n');
const result = await renewCertificates();
console.log('\nRenewal Results:');
console.log(` Attempted: ${result.attempted}`);
console.log(` Succeeded: ${result.succeeded.length}`);
console.log(` Failed: ${result.failed.length}`);
console.log(` Skipped: ${result.skipped.length}`);
if (result.succeeded.length > 0) {
console.log('\n✓ Renewed:');
for (const domain of result.succeeded) {
console.log(` - ${domain}`);
}
}
if (result.failed.length > 0) {
console.log('\n✗ Failed:');
for (const failure of result.failed) {
console.log(` - ${failure.domain}: ${failure.error}`);
}
}
if (result.skipped.length > 0) {
console.log('\n Skipped (not expiring soon):');
for (const domain of result.skipped) {
console.log(` - ${domain}`);
}
}
break;
}
case 'validate': {
const validation = await validateCertificates();
if (validation.valid) {
console.log('✓ All certificates are valid\n');
} else {
console.log('✗ Certificate validation failed:\n');
for (const error of validation.errors) {
console.log(` - ${error}`);
}
console.log('');
process.exit(1);
}
break;
}
default:
console.log('Usage:');
console.log(' ssl-manager.ts check - Check all certificate statuses');
console.log(' ssl-manager.ts request <domain> - Request new certificate');
console.log(' ssl-manager.ts renew - Renew expiring certificates');
console.log(' ssl-manager.ts validate - Validate all certificates');
console.log('\nAvailable domains:');
for (const config of PRODUCTION_DOMAINS) {
console.log(` - ${config.domain}`);
if (config.aliases) {
for (const alias of config.aliases) {
console.log(` (alias: ${alias})`);
}
}
}
process.exit(1);
}
} catch (error) {
console.error(`\n❌ Error: ${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
}
// Run CLI if executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch(error => {
console.error('Fatal error:', error);
process.exit(1);
});
}
// Export for use in other scripts
export { PRODUCTION_DOMAINS };