diff --git a/changelog.md b/changelog.md index c0141c5..7559be1 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2025-05-18 - 18.2.0 - feat(smartproxy/certificate) +Integrate HTTP-01 challenge handler into ACME certificate provisioning workflow + +- Added integration of SmartAcme HTTP01 handler to dynamically add and remove a challenge route for ACME certificate requests +- Updated certificate-manager to use the challenge handler for both initial provisioning and renewal +- Improved error handling and logging during certificate issuance, with clear status updates and cleanup of challenge routes + ## 2025-05-15 - 18.1.1 - fix(network-proxy/websocket) Improve WebSocket connection closure and update router integration diff --git a/test/test.smartacme-integration.ts b/test/test.smartacme-integration.ts new file mode 100644 index 0000000..69a8f13 --- /dev/null +++ b/test/test.smartacme-integration.ts @@ -0,0 +1,50 @@ +import * as plugins from '../ts/plugins.js'; +import { tap } from '@push.rocks/tapbundle'; +import { SmartCertManager } from '../ts/proxies/smart-proxy/certificate-manager.js'; +import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js'; + +let certManager: SmartCertManager; + +tap.test('should create a SmartCertManager instance', async () => { + const routes: IRouteConfig[] = [ + { + name: 'test-acme-route', + match: { + domains: ['test.example.com'] + }, + action: { + type: 'proxy', + target: 'http://localhost:3000', + tls: { + mode: 'terminate', + certificate: 'auto' + }, + acme: { + email: 'test@example.com' + } + } + } + ]; + + certManager = new SmartCertManager(routes, './test-certs', { + email: 'test@example.com', + useProduction: false + }); + + // Just verify it creates without error + expect(certManager).toBeInstanceOf(SmartCertManager); +}); + +tap.test('should verify SmartAcme handlers are accessible', async () => { + // Test that we can access SmartAcme handlers + const http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler(); + expect(http01Handler).toBeDefined(); +}); + +tap.test('should verify SmartAcme cert managers are accessible', async () => { + // Test that we can access SmartAcme cert managers + const memoryCertManager = new plugins.smartacme.certmanagers.MemoryCertManager(); + expect(memoryCertManager).toBeDefined(); +}); + +tap.start(); \ No newline at end of file diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 07a26df..0f292b3 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '18.1.1', + version: '18.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.' } diff --git a/ts/proxies/smart-proxy/certificate-manager.ts b/ts/proxies/smart-proxy/certificate-manager.ts index a9ffa79..cc29ad3 100644 --- a/ts/proxies/smart-proxy/certificate-manager.ts +++ b/ts/proxies/smart-proxy/certificate-manager.ts @@ -26,6 +26,7 @@ export class SmartCertManager { 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(); @@ -69,11 +70,18 @@ export class SmartCertManager { ); if (hasAcmeRoutes && this.acmeOptions?.email) { - // Create SmartAcme instance with built-in MemoryCertManager + // 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() + certManager: new plugins.smartacme.certmanagers.MemoryCertManager(), + challengeHandlers: [http01Handler] }); await this.smartAcme.start(); @@ -157,30 +165,43 @@ export class SmartCertManager { this.updateCertStatus(routeName, 'pending', 'acme'); try { - // Use smartacme to get certificate - const cert = await this.smartAcme.getCertificateForDomain(primaryDomain); + // Add challenge route before requesting certificate + await this.addChallengeRoute(); + + try { + // Use smartacme to get certificate + const cert = await this.smartAcme.getCertificateForDomain(primaryDomain); // SmartAcme's Cert object has these properties: - // - certPem: The certificate PEM string - // - privateKeyPem: The private key PEM string + // - publicKey: The certificate PEM string + // - privateKey: The private key PEM string // - csr: Certificate signing request - // - validUntil: Expiry date as Date object + // - validUntil: Timestamp in milliseconds // - domainName: The domain name const certData: ICertificateData = { - cert: cert.certPem, - key: cert.privateKeyPem, - ca: cert.certPem, // Use same as cert for now - expiryDate: cert.validUntil, - issueDate: new Date() // SmartAcme doesn't provide issue date + 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}`); + 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(); + } } catch (error) { - console.error(`Failed to provision ACME certificate for ${primaryDomain}: ${error}`); + // Handle outer try-catch from adding challenge route + console.error(`Failed to setup ACME challenge for ${primaryDomain}: ${error}`); this.updateCertStatus(routeName, 'error', 'acme', undefined, error.message); throw error; } @@ -287,40 +308,6 @@ export class SmartCertManager { return cert.expiryDate > expiryThreshold; } - /** - * Create ACME challenge route - * NOTE: SmartProxy already handles path-based routing and priority - */ - private createChallengeRoute(): IRouteConfig { - return { - name: 'acme-challenge', - priority: 1000, // High priority to ensure it's checked first - match: { - ports: 80, - path: '/.well-known/acme-challenge/*' - }, - action: { - type: 'static', - handler: async (context) => { - const token = context.path?.split('/').pop(); - const keyAuth = token ? this.pendingChallenges.get(token) : undefined; - - if (keyAuth) { - return { - status: 200, - headers: { 'Content-Type': 'text/plain' }, - body: keyAuth - }; - } else { - return { - status: 404, - body: 'Not found' - }; - } - } - } - }; - } /** * Add challenge route to SmartProxy @@ -330,9 +317,12 @@ export class SmartCertManager { throw new Error('No route update callback set'); } - const challengeRoute = this.createChallengeRoute(); - const updatedRoutes = [...this.routes, challengeRoute]; + if (!this.challengeRoute) { + throw new Error('Challenge route not initialized'); + } + const challengeRoute = this.challengeRoute; + const updatedRoutes = [...this.routes, challengeRoute]; await this.updateRoutesCallback(updatedRoutes); } @@ -424,27 +414,66 @@ export class SmartCertManager { } /** - * Handle ACME challenge + * Setup challenge handler integration with SmartProxy routing */ - private async handleChallenge(token: string, keyAuth: string): Promise { - this.pendingChallenges.set(token, keyAuth); + private setupChallengeHandler(http01Handler: plugins.smartacme.handlers.Http01MemoryHandler): void { + // 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, + 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' }; + } + } + } + }; - // Add challenge route if it's the first challenge - if (this.pendingChallenges.size === 1) { - await this.addChallengeRoute(); - } - } - - /** - * Cleanup ACME challenge - */ - private async cleanupChallenge(token: string): Promise { - this.pendingChallenges.delete(token); - - // Remove challenge route if no more challenges - if (this.pendingChallenges.size === 0) { - await this.removeChallengeRoute(); - } + // Store the challenge route to add it when needed + this.challengeRoute = challengeRoute; } /**