711 lines
18 KiB
JavaScript
Executable file
711 lines
18 KiB
JavaScript
Executable file
#!/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 };
|