import * as plugins from '../../plugins.js'; import { HttpProxy } from '../http-proxy/index.js'; import type { IRouteConfig, IRouteTls } from './models/route-types.js'; import type { IAcmeOptions } from './models/interfaces.js'; import { CertStore } from './cert-store.js'; import type { AcmeStateManager } from './acme-state-manager.js'; import { logger } from '../../core/utils/logger.js'; import { SocketHandlers } from './utils/route-helpers.js'; export interface ICertStatus { domain: string; status: 'valid' | 'pending' | 'expired' | 'error'; expiryDate?: Date; issueDate?: Date; source: 'static' | 'acme'; error?: string; } export interface ICertificateData { cert: string; key: string; ca?: string; expiryDate: Date; issueDate: Date; } export class SmartCertManager { private certStore: CertStore; private smartAcme: plugins.smartacme.SmartAcme | null = null; private httpProxy: HttpProxy | null = null; private renewalTimer: NodeJS.Timeout | null = null; private pendingChallenges: Map = new Map(); private challengeRoute: IRouteConfig | null = null; // Track certificate status by route name private certStatus: Map = new Map(); // Global ACME defaults from top-level configuration private globalAcmeDefaults: IAcmeOptions | null = null; // Callback to update SmartProxy routes for challenges private updateRoutesCallback?: (routes: IRouteConfig[]) => Promise; // Flag to track if challenge route is currently active private challengeRouteActive: boolean = false; // Flag to track if provisioning is in progress private isProvisioning: boolean = false; // ACME state manager reference private acmeStateManager: AcmeStateManager | null = null; constructor( private routes: IRouteConfig[], private certDir: string = './certs', private acmeOptions?: { email?: string; useProduction?: boolean; port?: number; }, private initialState?: { challengeRouteActive?: boolean; } ) { this.certStore = new CertStore(certDir); // Apply initial state if provided if (initialState) { this.challengeRouteActive = initialState.challengeRouteActive || false; } } public setHttpProxy(httpProxy: HttpProxy): void { this.httpProxy = httpProxy; } /** * Set the ACME state manager */ public setAcmeStateManager(stateManager: AcmeStateManager): void { this.acmeStateManager = stateManager; } /** * Set global ACME defaults from top-level configuration */ public setGlobalAcmeDefaults(defaults: IAcmeOptions): void { this.globalAcmeDefaults = defaults; } /** * Set callback for updating routes (used for challenge routes) */ public setUpdateRoutesCallback(callback: (routes: IRouteConfig[]) => Promise): void { this.updateRoutesCallback = callback; try { logger.log('debug', 'Route update callback set successfully', { component: 'certificate-manager' }); } catch (error) { // Silently handle logging errors console.log('[DEBUG] Route update callback set successfully'); } } /** * Initialize certificate manager and provision certificates for all routes */ public async initialize(): Promise { // Create certificate directory if it doesn't exist await this.certStore.initialize(); // Initialize SmartAcme if we have any ACME routes const hasAcmeRoutes = this.routes.some(r => r.action.tls?.certificate === 'auto' ); if (hasAcmeRoutes && this.acmeOptions?.email) { // Create HTTP-01 challenge handler const http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler(); // Set up challenge handler integration with our routing this.setupChallengeHandler(http01Handler); // Create SmartAcme instance with built-in MemoryCertManager and HTTP-01 handler this.smartAcme = new plugins.smartacme.SmartAcme({ accountEmail: this.acmeOptions.email, environment: this.acmeOptions.useProduction ? 'production' : 'integration', certManager: new plugins.smartacme.certmanagers.MemoryCertManager(), challengeHandlers: [http01Handler] }); await this.smartAcme.start(); // Add challenge route once at initialization if not already active if (!this.challengeRouteActive) { logger.log('info', 'Adding ACME challenge route during initialization', { component: 'certificate-manager' }); await this.addChallengeRoute(); } else { logger.log('info', 'Challenge route already active from previous instance', { component: 'certificate-manager' }); } } // Skip automatic certificate provisioning during initialization // This will be called later after ports are listening logger.log('info', 'Certificate manager initialized. Deferring certificate provisioning until after ports are listening.', { component: 'certificate-manager' }); // Start renewal timer this.startRenewalTimer(); } /** * Provision certificates for all routes that need them */ public async provisionAllCertificates(): Promise { const certRoutes = this.routes.filter(r => r.action.tls?.mode === 'terminate' || r.action.tls?.mode === 'terminate-and-reencrypt' ); // Set provisioning flag to prevent concurrent operations this.isProvisioning = true; try { for (const route of certRoutes) { try { await this.provisionCertificate(route, true); // Allow concurrent since we're managing it here } catch (error) { logger.log('error', `Failed to provision certificate for route ${route.name}`, { routeName: route.name, error, component: 'certificate-manager' }); } } } finally { this.isProvisioning = false; } } /** * Provision certificate for a single route */ public async provisionCertificate(route: IRouteConfig, allowConcurrent: boolean = false): Promise { const tls = route.action.tls; if (!tls || (tls.mode !== 'terminate' && tls.mode !== 'terminate-and-reencrypt')) { return; } // Check if provisioning is already in progress (prevent concurrent provisioning) if (!allowConcurrent && this.isProvisioning) { logger.log('info', `Certificate provisioning already in progress, skipping ${route.name}`, { routeName: route.name, component: 'certificate-manager' }); return; } const domains = this.extractDomainsFromRoute(route); if (domains.length === 0) { logger.log('warn', `Route ${route.name} has TLS termination but no domains`, { routeName: route.name, component: 'certificate-manager' }); return; } const primaryDomain = domains[0]; if (tls.certificate === 'auto') { // ACME certificate await this.provisionAcmeCertificate(route, domains); } else if (typeof tls.certificate === 'object') { // Static certificate await this.provisionStaticCertificate(route, primaryDomain, tls.certificate); } } /** * Provision ACME certificate */ private async provisionAcmeCertificate( route: IRouteConfig, domains: string[] ): Promise { if (!this.smartAcme) { throw new Error( 'SmartAcme not initialized. This usually means no ACME email was provided. ' + 'Please ensure you have configured ACME with an email address either:\n' + '1. In the top-level "acme" configuration\n' + '2. In the route\'s "tls.acme" configuration' ); } const primaryDomain = domains[0]; const routeName = route.name || primaryDomain; // Check if we already have a valid certificate const existingCert = await this.certStore.getCertificate(routeName); if (existingCert && this.isCertificateValid(existingCert)) { logger.log('info', `Using existing valid certificate for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' }); await this.applyCertificate(primaryDomain, existingCert); this.updateCertStatus(routeName, 'valid', 'acme', existingCert); return; } // Apply renewal threshold from global defaults or route config const renewThreshold = route.action.tls?.acme?.renewBeforeDays || this.globalAcmeDefaults?.renewThresholdDays || 30; logger.log('info', `Requesting ACME certificate for ${domains.join(', ')} (renew ${renewThreshold} days before expiry)`, { domains: domains.join(', '), renewThreshold, component: 'certificate-manager' }); this.updateCertStatus(routeName, 'pending', 'acme'); try { // Challenge route should already be active from initialization // No need to add it for each certificate // Determine if we should request a wildcard certificate // Only request wildcards if: // 1. The primary domain is not already a wildcard // 2. The domain has multiple parts (can have subdomains) // 3. We have DNS-01 challenge support (required for wildcards) const hasDnsChallenge = (this.smartAcme as any).challengeHandlers?.some((handler: any) => handler.getSupportedTypes && handler.getSupportedTypes().includes('dns-01') ); const shouldIncludeWildcard = !primaryDomain.startsWith('*.') && primaryDomain.includes('.') && primaryDomain.split('.').length >= 2 && hasDnsChallenge; if (shouldIncludeWildcard) { logger.log('info', `Requesting wildcard certificate for ${primaryDomain} (DNS-01 available)`, { domain: primaryDomain, challengeType: 'DNS-01', component: 'certificate-manager' }); } // Use smartacme to get certificate with optional wildcard const cert = await this.smartAcme.getCertificateForDomain( primaryDomain, shouldIncludeWildcard ? { includeWildcard: true } : undefined ); // SmartAcme's Cert object has these properties: // - publicKey: The certificate PEM string // - privateKey: The private key PEM string // - csr: Certificate signing request // - validUntil: Timestamp in milliseconds // - domainName: The domain name const certData: ICertificateData = { cert: cert.publicKey, key: cert.privateKey, ca: cert.publicKey, // Use same as cert for now expiryDate: new Date(cert.validUntil), issueDate: new Date(cert.created) }; await this.certStore.saveCertificate(routeName, certData); await this.applyCertificate(primaryDomain, certData); this.updateCertStatus(routeName, 'valid', 'acme', certData); logger.log('info', `Successfully provisioned ACME certificate for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' }); } catch (error) { logger.log('error', `Failed to provision ACME certificate for ${primaryDomain}: ${error.message}`, { domain: primaryDomain, error: error.message, component: 'certificate-manager' }); this.updateCertStatus(routeName, 'error', 'acme', undefined, error.message); throw error; } } /** * Provision static certificate */ private async provisionStaticCertificate( route: IRouteConfig, domain: string, certConfig: { key: string; cert: string; keyFile?: string; certFile?: string } ): Promise { const routeName = route.name || domain; try { let key: string = certConfig.key; let cert: string = certConfig.cert; // Load from files if paths are provided if (certConfig.keyFile) { const keyFile = await plugins.smartfile.SmartFile.fromFilePath(certConfig.keyFile); key = keyFile.contents.toString(); } if (certConfig.certFile) { const certFile = await plugins.smartfile.SmartFile.fromFilePath(certConfig.certFile); cert = certFile.contents.toString(); } // Parse certificate to get dates // Parse certificate to get dates - for now just use defaults // TODO: Implement actual certificate parsing if needed const certInfo = { validTo: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), validFrom: new Date() }; const certData: ICertificateData = { cert, key, expiryDate: certInfo.validTo, issueDate: certInfo.validFrom }; // Save to store for consistency await this.certStore.saveCertificate(routeName, certData); await this.applyCertificate(domain, certData); this.updateCertStatus(routeName, 'valid', 'static', certData); logger.log('info', `Successfully loaded static certificate for ${domain}`, { domain, component: 'certificate-manager' }); } catch (error) { logger.log('error', `Failed to provision static certificate for ${domain}: ${error.message}`, { domain, error: error.message, component: 'certificate-manager' }); this.updateCertStatus(routeName, 'error', 'static', undefined, error.message); throw error; } } /** * Apply certificate to HttpProxy */ private async applyCertificate(domain: string, certData: ICertificateData): Promise { if (!this.httpProxy) { logger.log('warn', `HttpProxy not set, cannot apply certificate for domain ${domain}`, { domain, component: 'certificate-manager' }); return; } // Apply certificate to HttpProxy this.httpProxy.updateCertificate(domain, certData.cert, certData.key); // Also apply for wildcard if it's a subdomain if (domain.includes('.') && !domain.startsWith('*.')) { const parts = domain.split('.'); if (parts.length >= 2) { const wildcardDomain = `*.${parts.slice(-2).join('.')}`; this.httpProxy.updateCertificate(wildcardDomain, certData.cert, certData.key); } } } /** * Extract domains from route configuration */ private extractDomainsFromRoute(route: IRouteConfig): string[] { if (!route.match.domains) { return []; } const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; // Filter out wildcards and patterns return domains.filter(d => !d.includes('*') && !d.includes('{') && d.includes('.') ); } /** * Check if certificate is valid */ private isCertificateValid(cert: ICertificateData): boolean { const now = new Date(); // Use renewal threshold from global defaults or fallback to 30 days const renewThresholdDays = this.globalAcmeDefaults?.renewThresholdDays || 30; const expiryThreshold = new Date(now.getTime() + renewThresholdDays * 24 * 60 * 60 * 1000); return cert.expiryDate > expiryThreshold; } /** * Add challenge route to SmartProxy * * This method adds a special route for ACME HTTP-01 challenges, which typically uses port 80. * Since we may already be listening on port 80 for regular routes, we need to be * careful about how we add this route to avoid binding conflicts. */ private async addChallengeRoute(): Promise { // Check with state manager first - avoid duplication if (this.acmeStateManager && this.acmeStateManager.isChallengeRouteActive()) { try { logger.log('info', 'Challenge route already active in global state, skipping', { component: 'certificate-manager' }); } catch (error) { // Silently handle logging errors console.log('[INFO] Challenge route already active in global state, skipping'); } this.challengeRouteActive = true; return; } if (this.challengeRouteActive) { try { logger.log('info', 'Challenge route already active locally, skipping', { component: 'certificate-manager' }); } catch (error) { // Silently handle logging errors console.log('[INFO] Challenge route already active locally, skipping'); } return; } if (!this.updateRoutesCallback) { throw new Error('No route update callback set'); } if (!this.challengeRoute) { throw new Error('Challenge route not initialized'); } // Get the challenge port const challengePort = this.globalAcmeDefaults?.port || 80; // Check if any existing routes are already using this port // This helps us determine if we need to create a new binding or can reuse existing one const portInUseByRoutes = this.routes.some(route => { const routePorts = Array.isArray(route.match.ports) ? route.match.ports : [route.match.ports]; return routePorts.some(p => { // Handle both number and port range objects if (typeof p === 'number') { return p === challengePort; } else if (typeof p === 'object' && 'from' in p && 'to' in p) { // Port range case - check if challengePort is in range return challengePort >= p.from && challengePort <= p.to; } return false; }); }); try { // Log whether port is already in use by other routes if (portInUseByRoutes) { try { logger.log('info', `Port ${challengePort} is already used by another route, merging ACME challenge route`, { port: challengePort, component: 'certificate-manager' }); } catch (error) { // Silently handle logging errors console.log(`[INFO] Port ${challengePort} is already used by another route, merging ACME challenge route`); } } else { try { logger.log('info', `Adding new ACME challenge route on port ${challengePort}`, { port: challengePort, component: 'certificate-manager' }); } catch (error) { // Silently handle logging errors console.log(`[INFO] Adding new ACME challenge route on port ${challengePort}`); } } // Add the challenge route to the existing routes const challengeRoute = this.challengeRoute; const updatedRoutes = [...this.routes, challengeRoute]; // With the re-ordering of start(), port binding should already be done // This updateRoutes call should just add the route without binding again await this.updateRoutesCallback(updatedRoutes); this.challengeRouteActive = true; // Register with state manager if (this.acmeStateManager) { this.acmeStateManager.addChallengeRoute(challengeRoute); } try { logger.log('info', 'ACME challenge route successfully added', { component: 'certificate-manager' }); } catch (error) { // Silently handle logging errors console.log('[INFO] ACME challenge route successfully added'); } } catch (error) { // Enhanced error handling based on error type if ((error as any).code === 'EADDRINUSE') { try { logger.log('warn', `Challenge port ${challengePort} is unavailable - it's already in use by another process. Consider configuring a different ACME port.`, { port: challengePort, error: (error as Error).message, component: 'certificate-manager' }); } catch (logError) { // Silently handle logging errors console.log(`[WARN] Challenge port ${challengePort} is unavailable - it's already in use by another process. Consider configuring a different ACME port.`); } // Provide a more informative and actionable error message throw new Error( `ACME HTTP-01 challenge port ${challengePort} is already in use by another process. ` + `Please configure a different port using the acme.port setting (e.g., 8080).` ); } else if (error.message && error.message.includes('EADDRINUSE')) { // Some Node.js versions embed the error code in the message rather than the code property try { logger.log('warn', `Port ${challengePort} conflict detected: ${error.message}`, { port: challengePort, component: 'certificate-manager' }); } catch (logError) { // Silently handle logging errors console.log(`[WARN] Port ${challengePort} conflict detected: ${error.message}`); } // More detailed error message with suggestions throw new Error( `ACME HTTP challenge port ${challengePort} conflict detected. ` + `To resolve this issue, try one of these approaches:\n` + `1. Configure a different port in ACME settings (acme.port)\n` + `2. Add a regular route that uses port ${challengePort} before initializing the certificate manager\n` + `3. Stop any other services that might be using port ${challengePort}` ); } // Log and rethrow other types of errors try { logger.log('error', `Failed to add challenge route: ${(error as Error).message}`, { error: (error as Error).message, component: 'certificate-manager' }); } catch (logError) { // Silently handle logging errors console.log(`[ERROR] Failed to add challenge route: ${(error as Error).message}`); } throw error; } } /** * Remove challenge route from SmartProxy */ private async removeChallengeRoute(): Promise { if (!this.challengeRouteActive) { try { logger.log('info', 'Challenge route not active, skipping removal', { component: 'certificate-manager' }); } catch (error) { // Silently handle logging errors console.log('[INFO] Challenge route not active, skipping removal'); } return; } if (!this.updateRoutesCallback) { return; } try { const filteredRoutes = this.routes.filter(r => r.name !== 'acme-challenge'); await this.updateRoutesCallback(filteredRoutes); this.challengeRouteActive = false; // Remove from state manager if (this.acmeStateManager) { this.acmeStateManager.removeChallengeRoute('acme-challenge'); } try { logger.log('info', 'ACME challenge route successfully removed', { component: 'certificate-manager' }); } catch (error) { // Silently handle logging errors console.log('[INFO] ACME challenge route successfully removed'); } } catch (error) { try { logger.log('error', `Failed to remove challenge route: ${error.message}`, { error: error.message, component: 'certificate-manager' }); } catch (logError) { // Silently handle logging errors console.log(`[ERROR] Failed to remove challenge route: ${error.message}`); } // Reset the flag even on error to avoid getting stuck this.challengeRouteActive = false; throw error; } } /** * Start renewal timer */ private startRenewalTimer(): void { // Check for renewals every 12 hours this.renewalTimer = setInterval(() => { this.checkAndRenewCertificates(); }, 12 * 60 * 60 * 1000); // Also do an immediate check this.checkAndRenewCertificates(); } /** * Check and renew certificates that are expiring */ private async checkAndRenewCertificates(): Promise { for (const route of this.routes) { if (route.action.tls?.certificate === 'auto') { const routeName = route.name || this.extractDomainsFromRoute(route)[0]; const cert = await this.certStore.getCertificate(routeName); if (cert && !this.isCertificateValid(cert)) { logger.log('info', `Certificate for ${routeName} needs renewal`, { routeName, component: 'certificate-manager' }); try { await this.provisionCertificate(route); } catch (error) { logger.log('error', `Failed to renew certificate for ${routeName}: ${error.message}`, { routeName, error: error.message, component: 'certificate-manager' }); } } } } } /** * Update certificate status */ private updateCertStatus( routeName: string, status: ICertStatus['status'], source: ICertStatus['source'], certData?: ICertificateData, error?: string ): void { this.certStatus.set(routeName, { domain: routeName, status, source, expiryDate: certData?.expiryDate, issueDate: certData?.issueDate, error }); } /** * Get certificate status for a route */ public getCertificateStatus(routeName: string): ICertStatus | undefined { return this.certStatus.get(routeName); } /** * Force renewal of a certificate */ public async renewCertificate(routeName: string): Promise { const route = this.routes.find(r => r.name === routeName); if (!route) { throw new Error(`Route ${routeName} not found`); } // Remove existing certificate to force renewal await this.certStore.deleteCertificate(routeName); await this.provisionCertificate(route); } /** * Setup challenge handler integration with SmartProxy routing */ private setupChallengeHandler(http01Handler: plugins.smartacme.handlers.Http01MemoryHandler): void { // Use challenge port from global config or default to 80 const challengePort = this.globalAcmeDefaults?.port || 80; // Create a challenge route that delegates to SmartAcme's HTTP-01 handler const challengeRoute: IRouteConfig = { name: 'acme-challenge', priority: 1000, // High priority match: { ports: challengePort, path: '/.well-known/acme-challenge/*' }, action: { type: 'socket-handler', socketHandler: SocketHandlers.httpServer((req, res) => { // Extract the token from the path const token = req.url?.split('/').pop(); if (!token) { res.status(404); res.send('Not found'); return; } // Create mock request/response objects for SmartAcme let responseData: any = null; const mockReq = { url: req.url, method: req.method, headers: req.headers }; const mockRes = { statusCode: 200, setHeader: (name: string, value: string) => {}, end: (data: any) => { responseData = data; } }; // Use SmartAcme's handler const handleAcme = () => { http01Handler.handleRequest(mockReq as any, mockRes as any, () => { // Not handled by ACME res.status(404); res.send('Not found'); }); // Give it a moment to process, then send response setTimeout(() => { if (responseData) { res.header('Content-Type', 'text/plain'); res.send(String(responseData)); } else { res.status(404); res.send('Not found'); } }, 100); }; handleAcme(); }) } }; // Store the challenge route to add it when needed this.challengeRoute = challengeRoute; } /** * Stop certificate manager */ public async stop(): Promise { if (this.renewalTimer) { clearInterval(this.renewalTimer); this.renewalTimer = null; } // Always remove challenge route on shutdown if (this.challengeRoute) { logger.log('info', 'Removing ACME challenge route during shutdown', { component: 'certificate-manager' }); await this.removeChallengeRoute(); } if (this.smartAcme) { await this.smartAcme.stop(); } // Clear any pending challenges if (this.pendingChallenges.size > 0) { this.pendingChallenges.clear(); } } /** * Get ACME options (for recreating after route updates) */ public getAcmeOptions(): { email?: string; useProduction?: boolean; port?: number } | undefined { return this.acmeOptions; } /** * Get certificate manager state */ public getState(): { challengeRouteActive: boolean } { return { challengeRouteActive: this.challengeRouteActive }; } }