fix(acme): Refactor ACME challenge route lifecycle to prevent port 80 EADDRINUSE errors
This commit is contained in:
		| @@ -3,6 +3,6 @@ | ||||
|  */ | ||||
| export const commitinfo = { | ||||
|   name: '@push.rocks/smartproxy', | ||||
|   version: '19.2.3', | ||||
|   version: '19.2.4', | ||||
|   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.' | ||||
| } | ||||
|   | ||||
| @@ -38,6 +38,12 @@ export class SmartCertManager { | ||||
|   // Callback to update SmartProxy routes for challenges | ||||
|   private updateRoutesCallback?: (routes: IRouteConfig[]) => Promise<void>; | ||||
|    | ||||
|   // 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; | ||||
|    | ||||
|   constructor( | ||||
|     private routes: IRouteConfig[], | ||||
|     private certDir: string = './certs', | ||||
| @@ -96,6 +102,10 @@ export class SmartCertManager { | ||||
|       }); | ||||
|        | ||||
|       await this.smartAcme.start(); | ||||
|        | ||||
|       // Add challenge route once at initialization | ||||
|       console.log('Adding ACME challenge route during initialization'); | ||||
|       await this.addChallengeRoute(); | ||||
|     } | ||||
|      | ||||
|     // Provision certificates for all routes | ||||
| @@ -114,24 +124,37 @@ export class SmartCertManager { | ||||
|       r.action.tls?.mode === 'terminate-and-reencrypt' | ||||
|     ); | ||||
|      | ||||
|     for (const route of certRoutes) { | ||||
|       try { | ||||
|         await this.provisionCertificate(route); | ||||
|       } catch (error) { | ||||
|         console.error(`Failed to provision certificate for route ${route.name}: ${error}`); | ||||
|     // 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): Promise<void> { | ||||
|   public async provisionCertificate(route: IRouteConfig, allowConcurrent: boolean = false): Promise<void> { | ||||
|     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`); | ||||
| @@ -186,13 +209,12 @@ export class SmartCertManager { | ||||
|     this.updateCertStatus(routeName, 'pending', 'acme'); | ||||
|      | ||||
|     try { | ||||
|       // Add challenge route before requesting certificate | ||||
|       await this.addChallengeRoute(); | ||||
|        | ||||
|       try { | ||||
|         // Use smartacme to get certificate | ||||
|         const cert = await this.smartAcme.getCertificateForDomain(primaryDomain); | ||||
|       // 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 | ||||
| @@ -211,18 +233,9 @@ export class SmartCertManager { | ||||
|       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; | ||||
|       } finally { | ||||
|         // Always remove challenge route after provisioning | ||||
|         await this.removeChallengeRoute(); | ||||
|       } | ||||
|       console.log(`Successfully provisioned ACME certificate for ${primaryDomain}`); | ||||
|     } catch (error) { | ||||
|       // Handle outer try-catch from adding challenge route | ||||
|       console.error(`Failed to setup ACME challenge for ${primaryDomain}: ${error}`); | ||||
|       console.error(`Failed to provision ACME certificate for ${primaryDomain}: ${error}`); | ||||
|       this.updateCertStatus(routeName, 'error', 'acme', undefined, error.message); | ||||
|       throw error; | ||||
|     } | ||||
| @@ -337,6 +350,11 @@ export class SmartCertManager { | ||||
|    * Add challenge route to SmartProxy | ||||
|    */ | ||||
|   private async addChallengeRoute(): Promise<void> { | ||||
|     if (this.challengeRouteActive) { | ||||
|       console.log('Challenge route already active, skipping'); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     if (!this.updateRoutesCallback) { | ||||
|       throw new Error('No route update callback set'); | ||||
|     } | ||||
| @@ -346,20 +364,44 @@ export class SmartCertManager { | ||||
|     } | ||||
|     const challengeRoute = this.challengeRoute; | ||||
|      | ||||
|     const updatedRoutes = [...this.routes, challengeRoute]; | ||||
|     await this.updateRoutesCallback(updatedRoutes); | ||||
|     try { | ||||
|       const updatedRoutes = [...this.routes, challengeRoute]; | ||||
|       await this.updateRoutesCallback(updatedRoutes); | ||||
|       this.challengeRouteActive = true; | ||||
|       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<void> { | ||||
|     if (!this.challengeRouteActive) { | ||||
|       console.log('Challenge route not active, skipping removal'); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     if (!this.updateRoutesCallback) { | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     const filteredRoutes = this.routes.filter(r => r.name !== 'acme-challenge'); | ||||
|     await this.updateRoutesCallback(filteredRoutes); | ||||
|     try { | ||||
|       const filteredRoutes = this.routes.filter(r => r.name !== 'acme-challenge'); | ||||
|       await this.updateRoutesCallback(filteredRoutes); | ||||
|       this.challengeRouteActive = false; | ||||
|       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; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
| @@ -512,14 +554,19 @@ export class SmartCertManager { | ||||
|       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(); | ||||
|     } | ||||
|      | ||||
|     // Remove any active challenge routes | ||||
|     // Clear any pending challenges | ||||
|     if (this.pendingChallenges.size > 0) { | ||||
|       this.pendingChallenges.clear(); | ||||
|       await this.removeChallengeRoute(); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   | ||||
		Reference in New Issue
	
	Block a user