import * as plugins from '../../plugins.js'; import { NetworkProxy } from '../network-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'; 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 networkProxy: NetworkProxy | 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 setNetworkProxy(networkProxy: NetworkProxy): void { this.networkProxy = networkProxy; } /** * Get the current state of the certificate manager */ public getState(): { challengeRouteActive: boolean } { return { challengeRouteActive: this.challengeRouteActive }; } /** * 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; } /** * 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) { console.log('Adding ACME challenge route during initialization'); await this.addChallengeRoute(); } else { console.log('Challenge route already active from previous instance'); } } // Provision certificates for all routes await this.provisionAllCertificates(); // Start renewal timer this.startRenewalTimer(); } /** * Provision certificates for all routes that need them */ private 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) { console.error(`Failed to provision certificate for route ${route.name}: ${error}`); } } } 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) { console.log(`Certificate provisioning already in progress, skipping ${route.name}`); return; } const domains = this.extractDomainsFromRoute(route); if (domains.length === 0) { console.warn(`Route ${route.name} has TLS termination but no domains`); 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)) { console.log(`Using existing valid certificate for ${primaryDomain}`); 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; console.log(`Requesting ACME certificate for ${domains.join(', ')} (renew ${renewThreshold} days before expiry)`); this.updateCertStatus(routeName, 'pending', 'acme'); try { // Challenge route should already be active from initialization // No need to add it for each certificate // Use smartacme to get certificate const cert = await this.smartAcme.getCertificateForDomain(primaryDomain); // 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); console.log(`Successfully provisioned ACME certificate for ${primaryDomain}`); } catch (error) { console.error(`Failed to provision ACME certificate for ${primaryDomain}: ${error}`); 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); console.log(`Successfully loaded static certificate for ${domain}`); } catch (error) { console.error(`Failed to provision static certificate for ${domain}: ${error}`); this.updateCertStatus(routeName, 'error', 'static', undefined, error.message); throw error; } } /** * Apply certificate to NetworkProxy */ private async applyCertificate(domain: string, certData: ICertificateData): Promise { if (!this.networkProxy) { console.warn('NetworkProxy not set, cannot apply certificate'); return; } // Apply certificate to NetworkProxy this.networkProxy.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.networkProxy.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 */ private async addChallengeRoute(): Promise { // Check with state manager first if (this.acmeStateManager && this.acmeStateManager.isChallengeRouteActive()) { console.log('Challenge route already active in global state, skipping'); this.challengeRouteActive = true; return; } if (this.challengeRouteActive) { console.log('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'); } const challengeRoute = this.challengeRoute; try { const updatedRoutes = [...this.routes, challengeRoute]; await this.updateRoutesCallback(updatedRoutes); this.challengeRouteActive = true; // Register with state manager if (this.acmeStateManager) { this.acmeStateManager.addChallengeRoute(challengeRoute); } console.log('ACME challenge route successfully added'); } catch (error) { console.error('Failed to add challenge route:', error); if ((error as any).code === 'EADDRINUSE') { throw new Error(`Port ${this.globalAcmeDefaults?.port || 80} is already in use for ACME challenges`); } throw error; } } /** * Remove challenge route from SmartProxy */ private async removeChallengeRoute(): Promise { if (!this.challengeRouteActive) { console.log('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'); } console.log('ACME challenge route successfully removed'); } catch (error) { console.error('Failed to remove challenge route:', error); // 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)) { console.log(`Certificate for ${routeName} needs renewal`); try { await this.provisionCertificate(route); } catch (error) { console.error(`Failed to renew certificate for ${routeName}: ${error}`); } } } } } /** * 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: 'static', handler: async (context) => { // Extract the token from the path const token = context.path?.split('/').pop(); if (!token) { return { status: 404, body: 'Not found' }; } // Create mock request/response objects for SmartAcme const mockReq = { url: context.path, method: 'GET', headers: context.headers || {} }; let responseData: any = null; const mockRes = { statusCode: 200, setHeader: (name: string, value: string) => {}, end: (data: any) => { responseData = data; } }; // Use SmartAcme's handler const handled = await new Promise((resolve) => { http01Handler.handleRequest(mockReq as any, mockRes as any, () => { resolve(false); }); // Give it a moment to process setTimeout(() => resolve(true), 100); }); if (handled && responseData) { return { status: mockRes.statusCode, headers: { 'Content-Type': 'text/plain' }, body: responseData }; } else { return { status: 404, body: 'Not found' }; } } } }; // 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) { console.log('Removing ACME challenge route during shutdown'); 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; } }