feat(acme): Improve certificate management by adding global ACME configuration support and allowing route-level overrides. Enhanced error messages help identify missing ACME email and misconfigurations (e.g. wildcard domains). Documentation has been updated and new tests added to verify SmartCertManager behavior, ensuring a clearer migration path from legacy implementations.
This commit is contained in:
		| @@ -3,6 +3,6 @@ | ||||
|  */ | ||||
| export const commitinfo = { | ||||
|   name: '@push.rocks/smartproxy', | ||||
|   version: '19.1.0', | ||||
|   version: '19.2.0', | ||||
|   description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| 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'; | ||||
|  | ||||
| export interface ICertStatus { | ||||
| @@ -31,6 +32,9 @@ export class SmartCertManager { | ||||
|   // Track certificate status by route name | ||||
|   private certStatus: Map<string, ICertStatus> = 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<void>; | ||||
|    | ||||
| @@ -50,6 +54,13 @@ export class SmartCertManager { | ||||
|     this.networkProxy = networkProxy; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * 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) | ||||
|    */ | ||||
| @@ -146,7 +157,12 @@ export class SmartCertManager { | ||||
|     domains: string[] | ||||
|   ): Promise<void> { | ||||
|     if (!this.smartAcme) { | ||||
|       throw new Error('SmartAcme not initialized'); | ||||
|       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]; | ||||
| @@ -161,7 +177,12 @@ export class SmartCertManager { | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     console.log(`Requesting ACME certificate for ${domains.join(', ')}`); | ||||
|     // 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 { | ||||
| @@ -303,7 +324,10 @@ export class SmartCertManager { | ||||
|    */ | ||||
|   private isCertificateValid(cert: ICertificateData): boolean { | ||||
|     const now = new Date(); | ||||
|     const expiryThreshold = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days | ||||
|      | ||||
|     // 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; | ||||
|   } | ||||
| @@ -417,12 +441,15 @@ export class SmartCertManager { | ||||
|    * 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: 80, | ||||
|         ports: challengePort, | ||||
|         path: '/.well-known/acme-challenge/*' | ||||
|       }, | ||||
|       action: { | ||||
|   | ||||
| @@ -2,15 +2,16 @@ import * as plugins from '../../../plugins.js'; | ||||
| // Certificate types removed - define IAcmeOptions locally | ||||
| export interface IAcmeOptions { | ||||
|   enabled?: boolean; | ||||
|   email?: string; | ||||
|   email?: string; // Required when any route uses certificate: 'auto' | ||||
|   environment?: 'production' | 'staging'; | ||||
|   port?: number; | ||||
|   useProduction?: boolean; | ||||
|   renewThresholdDays?: number; | ||||
|   autoRenew?: boolean; | ||||
|   certificateStore?: string; | ||||
|   accountEmail?: string; // Alias for email | ||||
|   port?: number; // Port for HTTP-01 challenges (default: 80) | ||||
|   useProduction?: boolean; // Use Let's Encrypt production (default: false) | ||||
|   renewThresholdDays?: number; // Days before expiry to renew (default: 30) | ||||
|   autoRenew?: boolean; // Enable automatic renewal (default: true) | ||||
|   certificateStore?: string; // Directory to store certificates (default: './certs') | ||||
|   skipConfiguredCerts?: boolean; | ||||
|   renewCheckIntervalHours?: number; | ||||
|   renewCheckIntervalHours?: number; // How often to check for renewals (default: 24) | ||||
|   routeForwards?: any[]; | ||||
| } | ||||
| import type { IRouteConfig } from './route-types.js'; | ||||
| @@ -97,7 +98,22 @@ export interface ISmartProxyOptions { | ||||
|   useNetworkProxy?: number[]; // Array of ports to forward to NetworkProxy | ||||
|   networkProxyPort?: number; // Port where NetworkProxy is listening (default: 8443) | ||||
|  | ||||
|   // ACME configuration options for SmartProxy | ||||
|   /** | ||||
|    * Global ACME configuration options for SmartProxy | ||||
|    *  | ||||
|    * When set, these options will be used as defaults for all routes | ||||
|    * with certificate: 'auto' that don't have their own ACME configuration. | ||||
|    * Route-specific ACME settings will override these defaults. | ||||
|    *  | ||||
|    * Example: | ||||
|    * ```ts | ||||
|    * acme: { | ||||
|    *   email: 'ssl@example.com', | ||||
|    *   useProduction: false, | ||||
|    *   port: 80 | ||||
|    * } | ||||
|    * ``` | ||||
|    */ | ||||
|   acme?: IAcmeOptions; | ||||
|  | ||||
|   /** | ||||
|   | ||||
| @@ -115,20 +115,26 @@ export class SmartProxy extends plugins.EventEmitter { | ||||
|       networkProxyPort: settingsArg.networkProxyPort || 8443, | ||||
|     }; | ||||
|      | ||||
|     // Set default ACME options if not provided | ||||
|     this.settings.acme = this.settings.acme || {}; | ||||
|     if (Object.keys(this.settings.acme).length === 0) { | ||||
|     // Normalize ACME options if provided (support both email and accountEmail) | ||||
|     if (this.settings.acme) { | ||||
|       // Support both 'email' and 'accountEmail' fields | ||||
|       if (this.settings.acme.accountEmail && !this.settings.acme.email) { | ||||
|         this.settings.acme.email = this.settings.acme.accountEmail; | ||||
|       } | ||||
|        | ||||
|       // Set reasonable defaults for commonly used fields | ||||
|       this.settings.acme = { | ||||
|         enabled: false, | ||||
|         port: 80, | ||||
|         email: 'admin@example.com', | ||||
|         useProduction: false, | ||||
|         renewThresholdDays: 30, | ||||
|         autoRenew: true, | ||||
|         certificateStore: './certs', | ||||
|         skipConfiguredCerts: false, | ||||
|         renewCheckIntervalHours: 24, | ||||
|         routeForwards: [] | ||||
|         enabled: this.settings.acme.enabled !== false, // Enable by default if acme object exists | ||||
|         port: this.settings.acme.port || 80, | ||||
|         email: this.settings.acme.email, | ||||
|         useProduction: this.settings.acme.useProduction || false, | ||||
|         renewThresholdDays: this.settings.acme.renewThresholdDays || 30, | ||||
|         autoRenew: this.settings.acme.autoRenew !== false, // Enable by default | ||||
|         certificateStore: this.settings.acme.certificateStore || './certs', | ||||
|         skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false, | ||||
|         renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours || 24, | ||||
|         routeForwards: this.settings.acme.routeForwards || [], | ||||
|         ...this.settings.acme // Preserve any additional fields | ||||
|       }; | ||||
|     } | ||||
|      | ||||
| @@ -186,19 +192,55 @@ export class SmartProxy extends plugins.EventEmitter { | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     // Use the first auto route's ACME config as defaults | ||||
|     const defaultAcme = autoRoutes[0]?.action.tls?.acme; | ||||
|     // Prepare ACME options with priority: | ||||
|     // 1. Use top-level ACME config if available | ||||
|     // 2. Fall back to first auto route's ACME config | ||||
|     // 3. Otherwise use undefined | ||||
|     let acmeOptions: { email?: string; useProduction?: boolean; port?: number } | undefined; | ||||
|      | ||||
|     if (this.settings.acme?.email) { | ||||
|       // Use top-level ACME config | ||||
|       acmeOptions = { | ||||
|         email: this.settings.acme.email, | ||||
|         useProduction: this.settings.acme.useProduction || false, | ||||
|         port: this.settings.acme.port || 80 | ||||
|       }; | ||||
|       console.log(`Using top-level ACME configuration with email: ${acmeOptions.email}`); | ||||
|     } else if (autoRoutes.length > 0) { | ||||
|       // Check for route-level ACME config | ||||
|       const routeWithAcme = autoRoutes.find(r => r.action.tls?.acme?.email); | ||||
|       if (routeWithAcme?.action.tls?.acme) { | ||||
|         const routeAcme = routeWithAcme.action.tls.acme; | ||||
|         acmeOptions = { | ||||
|           email: routeAcme.email, | ||||
|           useProduction: routeAcme.useProduction || false, | ||||
|           port: routeAcme.challengePort || 80 | ||||
|         }; | ||||
|         console.log(`Using route-level ACME configuration from route '${routeWithAcme.name}' with email: ${acmeOptions.email}`); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // Validate we have required configuration | ||||
|     if (autoRoutes.length > 0 && !acmeOptions?.email) { | ||||
|       throw new Error( | ||||
|         'ACME email is required for automatic certificate provisioning. ' + | ||||
|         'Please provide email in either:\n' + | ||||
|         '1. Top-level "acme" configuration\n' + | ||||
|         '2. Individual route\'s "tls.acme" configuration' | ||||
|       ); | ||||
|     } | ||||
|      | ||||
|     this.certManager = new SmartCertManager( | ||||
|       this.settings.routes, | ||||
|       './certs', // Certificate directory | ||||
|       defaultAcme ? { | ||||
|         email: defaultAcme.email, | ||||
|         useProduction: defaultAcme.useProduction, | ||||
|         port: defaultAcme.challengePort || 80 | ||||
|       } : undefined | ||||
|       this.settings.acme?.certificateStore || './certs', | ||||
|       acmeOptions | ||||
|     ); | ||||
|      | ||||
|     // Pass down the global ACME config to the cert manager | ||||
|     if (this.settings.acme) { | ||||
|       this.certManager.setGlobalAcmeDefaults(this.settings.acme); | ||||
|     } | ||||
|      | ||||
|     // Connect with NetworkProxy | ||||
|     if (this.networkProxyBridge.getNetworkProxy()) { | ||||
|       this.certManager.setNetworkProxy(this.networkProxyBridge.getNetworkProxy()); | ||||
| @@ -249,9 +291,14 @@ export class SmartProxy extends plugins.EventEmitter { | ||||
|  | ||||
|     // Validate the route configuration | ||||
|     const configWarnings = this.routeManager.validateConfiguration(); | ||||
|     if (configWarnings.length > 0) { | ||||
|       console.log("Route configuration warnings:"); | ||||
|       for (const warning of configWarnings) { | ||||
|      | ||||
|     // Also validate ACME configuration | ||||
|     const acmeWarnings = this.validateAcmeConfiguration(); | ||||
|     const allWarnings = [...configWarnings, ...acmeWarnings]; | ||||
|      | ||||
|     if (allWarnings.length > 0) { | ||||
|       console.log("Configuration warnings:"); | ||||
|       for (const warning of allWarnings) { | ||||
|         console.log(` - ${warning}`); | ||||
|       } | ||||
|     } | ||||
| @@ -663,5 +710,76 @@ export class SmartProxy extends plugins.EventEmitter { | ||||
|   public async getNfTablesStatus(): Promise<Record<string, any>> { | ||||
|     return this.nftablesManager.getStatus(); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Validate ACME configuration | ||||
|    */ | ||||
|   private validateAcmeConfiguration(): string[] { | ||||
|     const warnings: string[] = []; | ||||
|      | ||||
|     // Check for routes with certificate: 'auto' | ||||
|     const autoRoutes = this.settings.routes.filter(r =>  | ||||
|       r.action.tls?.certificate === 'auto' | ||||
|     ); | ||||
|      | ||||
|     if (autoRoutes.length === 0) { | ||||
|       return warnings; | ||||
|     } | ||||
|      | ||||
|     // Check if we have ACME email configuration | ||||
|     const hasTopLevelEmail = this.settings.acme?.email; | ||||
|     const routesWithEmail = autoRoutes.filter(r => r.action.tls?.acme?.email); | ||||
|      | ||||
|     if (!hasTopLevelEmail && routesWithEmail.length === 0) { | ||||
|       warnings.push( | ||||
|         'Routes with certificate: "auto" require ACME email configuration. ' + | ||||
|         'Add email to either top-level "acme" config or individual route\'s "tls.acme" config.' | ||||
|       ); | ||||
|     } | ||||
|      | ||||
|     // Check for port 80 availability for challenges | ||||
|     if (autoRoutes.length > 0) { | ||||
|       const challengePort = this.settings.acme?.port || 80; | ||||
|       const portsInUse = this.routeManager.getListeningPorts(); | ||||
|        | ||||
|       if (!portsInUse.includes(challengePort)) { | ||||
|         warnings.push( | ||||
|           `Port ${challengePort} is not configured for any routes but is needed for ACME challenges. ` + | ||||
|           `Add a route listening on port ${challengePort} or ensure it's accessible for HTTP-01 challenges.` | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // Check for mismatched environments | ||||
|     if (this.settings.acme?.useProduction) { | ||||
|       const stagingRoutes = autoRoutes.filter(r =>  | ||||
|         r.action.tls?.acme?.useProduction === false | ||||
|       ); | ||||
|       if (stagingRoutes.length > 0) { | ||||
|         warnings.push( | ||||
|           'Top-level ACME uses production but some routes use staging. ' + | ||||
|           'Consider aligning environments to avoid certificate issues.' | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // Check for wildcard domains with auto certificates | ||||
|     for (const route of autoRoutes) { | ||||
|       const domains = Array.isArray(route.match.domains)  | ||||
|         ? route.match.domains  | ||||
|         : [route.match.domains]; | ||||
|        | ||||
|       const wildcardDomains = domains.filter(d => d?.includes('*')); | ||||
|       if (wildcardDomains.length > 0) { | ||||
|         warnings.push( | ||||
|           `Route "${route.name}" has wildcard domain(s) ${wildcardDomains.join(', ')} ` + | ||||
|           'with certificate: "auto". Wildcard certificates require DNS-01 challenges, ' + | ||||
|           'which are not currently supported. Use static certificates instead.' | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     return warnings; | ||||
|   } | ||||
|  | ||||
| } | ||||
		Reference in New Issue
	
	Block a user