diff --git a/test/test.certificate-provision.ts b/test/test.certificate-provision.ts new file mode 100644 index 0000000..33fec90 --- /dev/null +++ b/test/test.certificate-provision.ts @@ -0,0 +1,350 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { SmartProxy } from '../ts/index.js'; +import type { TSmartProxyCertProvisionObject } from '../ts/index.js'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +let testProxy: SmartProxy; + +// Load test certificates from helpers +const testCert = fs.readFileSync(path.join(__dirname, 'helpers/test-cert.pem'), 'utf8'); +const testKey = fs.readFileSync(path.join(__dirname, 'helpers/test-key.pem'), 'utf8'); + +tap.test('SmartProxy should support custom certificate provision function', async () => { + // Create test certificate object - the type is from plugins.tsclass.network.ICert + // but we return a simple object with the required properties + const testCertObject = { + cert: testCert, + key: testKey, + ca: '' + }; + + // Custom certificate store for testing + const customCerts = new Map(); + customCerts.set('test.example.com', testCertObject); + + // Create proxy with custom certificate provision + testProxy = new SmartProxy({ + certProvisionFunction: async (domain: string): Promise => { + console.log(`Custom cert provision called for domain: ${domain}`); + + // Return custom cert for known domains + if (customCerts.has(domain)) { + console.log(`Returning custom certificate for ${domain}`); + return customCerts.get(domain)! as unknown as TSmartProxyCertProvisionObject; + } + + // Fallback to Let's Encrypt for other domains + console.log(`Falling back to Let's Encrypt for ${domain}`); + return 'http01'; + }, + certProvisionFallbackToAcme: true, + acme: { + email: 'test@example.com', + useProduction: false + }, + routes: [ + { + name: 'test-route', + match: { + ports: [443], + domains: ['test.example.com'] + }, + action: { + type: 'forward', + target: { + host: 'localhost', + port: 8080 + }, + tls: { + mode: 'terminate', + certificate: 'auto' + } + } + } + ] + }); + + expect(testProxy).toBeInstanceOf(SmartProxy); +}); + +tap.test('Custom certificate provision function should be called', async () => { + let provisionCalled = false; + const provisionedDomains: string[] = []; + + const testProxy2 = new SmartProxy({ + certProvisionFunction: async (domain: string): Promise => { + provisionCalled = true; + provisionedDomains.push(domain); + + // Return a test certificate using the loaded files + // We need to return a proper cert object that satisfies the type + return { + cert: testCert, + key: testKey, + ca: '' + } as unknown as TSmartProxyCertProvisionObject; + }, + acme: { + email: 'test@example.com', + useProduction: false, + port: 9080 + }, + routes: [ + { + name: 'custom-cert-route', + match: { + ports: [9443], + domains: ['custom.example.com'] + }, + action: { + type: 'forward', + target: { + host: 'localhost', + port: 8080 + }, + tls: { + mode: 'terminate', + certificate: 'auto' + } + } + } + ] + }); + + // Mock the certificate manager to test our custom provision function + let certManagerCalled = false; + const origCreateCertManager = (testProxy2 as any).createCertificateManager; + (testProxy2 as any).createCertificateManager = async function(...args: any[]) { + const certManager = await origCreateCertManager.apply(testProxy2, args); + + // Override provisionAllCertificates to track calls + const origProvisionAll = certManager.provisionAllCertificates; + certManager.provisionAllCertificates = async function() { + certManagerCalled = true; + await origProvisionAll.call(certManager); + }; + + return certManager; + }; + + // Start the proxy (this will trigger certificate provisioning) + await testProxy2.start(); + + expect(certManagerCalled).toBeTrue(); + expect(provisionCalled).toBeTrue(); + expect(provisionedDomains).toContain('custom.example.com'); + + await testProxy2.stop(); +}); + +tap.test('Should fallback to ACME when custom provision fails', async () => { + const failedDomains: string[] = []; + let acmeAttempted = false; + + const testProxy3 = new SmartProxy({ + certProvisionFunction: async (domain: string): Promise => { + failedDomains.push(domain); + throw new Error('Custom provision failed for testing'); + }, + certProvisionFallbackToAcme: true, + acme: { + email: 'test@example.com', + useProduction: false, + port: 9080 + }, + routes: [ + { + name: 'fallback-route', + match: { + ports: [9444], + domains: ['fallback.example.com'] + }, + action: { + type: 'forward', + target: { + host: 'localhost', + port: 8080 + }, + tls: { + mode: 'terminate', + certificate: 'auto' + } + } + } + ] + }); + + // Mock to track ACME attempts + const origCreateCertManager = (testProxy3 as any).createCertificateManager; + (testProxy3 as any).createCertificateManager = async function(...args: any[]) { + const certManager = await origCreateCertManager.apply(testProxy3, args); + + // Mock SmartAcme to avoid real ACME calls + (certManager as any).smartAcme = { + getCertificateForDomain: async () => { + acmeAttempted = true; + throw new Error('Mocked ACME failure'); + } + }; + + return certManager; + }; + + // Start the proxy + await testProxy3.start(); + + // Custom provision should have failed + expect(failedDomains).toContain('fallback.example.com'); + + // ACME should have been attempted as fallback + expect(acmeAttempted).toBeTrue(); + + await testProxy3.stop(); +}); + +tap.test('Should not fallback when certProvisionFallbackToAcme is false', async () => { + let errorThrown = false; + let errorMessage = ''; + + const testProxy4 = new SmartProxy({ + certProvisionFunction: async (_domain: string): Promise => { + throw new Error('Custom provision failed for testing'); + }, + certProvisionFallbackToAcme: false, + routes: [ + { + name: 'no-fallback-route', + match: { + ports: [9445], + domains: ['no-fallback.example.com'] + }, + action: { + type: 'forward', + target: { + host: 'localhost', + port: 8080 + }, + tls: { + mode: 'terminate', + certificate: 'auto' + } + } + } + ] + }); + + // Mock certificate manager to capture errors + const origCreateCertManager = (testProxy4 as any).createCertificateManager; + (testProxy4 as any).createCertificateManager = async function(...args: any[]) { + const certManager = await origCreateCertManager.apply(testProxy4, args); + + // Override provisionAllCertificates to capture errors + const origProvisionAll = certManager.provisionAllCertificates; + certManager.provisionAllCertificates = async function() { + try { + await origProvisionAll.call(certManager); + } catch (e) { + errorThrown = true; + errorMessage = e.message; + throw e; + } + }; + + return certManager; + }; + + try { + await testProxy4.start(); + } catch (e) { + // Expected to fail + } + + expect(errorThrown).toBeTrue(); + expect(errorMessage).toInclude('Custom provision failed for testing'); + + await testProxy4.stop(); +}); + +tap.test('Should return http01 for unknown domains', async () => { + let returnedHttp01 = false; + let acmeAttempted = false; + + const testProxy5 = new SmartProxy({ + certProvisionFunction: async (domain: string): Promise => { + if (domain === 'known.example.com') { + return { + cert: testCert, + key: testKey, + ca: '' + } as unknown as TSmartProxyCertProvisionObject; + } + returnedHttp01 = true; + return 'http01'; + }, + acme: { + email: 'test@example.com', + useProduction: false, + port: 9081 + }, + routes: [ + { + name: 'unknown-domain-route', + match: { + ports: [9446], + domains: ['unknown.example.com'] + }, + action: { + type: 'forward', + target: { + host: 'localhost', + port: 8080 + }, + tls: { + mode: 'terminate', + certificate: 'auto' + } + } + } + ] + }); + + // Mock to track ACME attempts + const origCreateCertManager = (testProxy5 as any).createCertificateManager; + (testProxy5 as any).createCertificateManager = async function(...args: any[]) { + const certManager = await origCreateCertManager.apply(testProxy5, args); + + // Mock SmartAcme to track attempts + (certManager as any).smartAcme = { + getCertificateForDomain: async () => { + acmeAttempted = true; + throw new Error('Mocked ACME failure'); + } + }; + + return certManager; + }; + + await testProxy5.start(); + + // Should have returned http01 for unknown domain + expect(returnedHttp01).toBeTrue(); + + // ACME should have been attempted + expect(acmeAttempted).toBeTrue(); + + await testProxy5.stop(); +}); + +tap.test('cleanup', async () => { + // Clean up any test proxies + if (testProxy) { + await testProxy.stop(); + } +}); + +export default tap.start(); \ No newline at end of file diff --git a/ts/proxies/smart-proxy/certificate-manager.ts b/ts/proxies/smart-proxy/certificate-manager.ts index eb20c3b..3eac595 100644 --- a/ts/proxies/smart-proxy/certificate-manager.ts +++ b/ts/proxies/smart-proxy/certificate-manager.ts @@ -12,7 +12,7 @@ export interface ICertStatus { status: 'valid' | 'pending' | 'expired' | 'error'; expiryDate?: Date; issueDate?: Date; - source: 'static' | 'acme'; + source: 'static' | 'acme' | 'custom'; error?: string; } @@ -22,6 +22,7 @@ export interface ICertificateData { ca?: string; expiryDate: Date; issueDate: Date; + source?: 'static' | 'acme' | 'custom'; } export class SmartCertManager { @@ -50,6 +51,12 @@ export class SmartCertManager { // ACME state manager reference private acmeStateManager: AcmeStateManager | null = null; + // Custom certificate provision function + private certProvisionFunction?: (domain: string) => Promise; + + // Whether to fallback to ACME if custom provision fails + private certProvisionFallbackToAcme: boolean = true; + constructor( private routes: IRouteConfig[], private certDir: string = './certs', @@ -89,6 +96,20 @@ export class SmartCertManager { this.globalAcmeDefaults = defaults; } + /** + * Set custom certificate provision function + */ + public setCertProvisionFunction(fn: (domain: string) => Promise): void { + this.certProvisionFunction = fn; + } + + /** + * Set whether to fallback to ACME if custom provision fails + */ + public setCertProvisionFallbackToAcme(fallback: boolean): void { + this.certProvisionFallbackToAcme = fallback; + } + /** * Set callback for updating routes (used for challenge routes) */ @@ -212,15 +233,6 @@ export class SmartCertManager { 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; @@ -229,10 +241,68 @@ export class SmartCertManager { 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); + this.updateCertStatus(routeName, 'valid', existingCert.source || 'acme', existingCert); return; } + // Check for custom provision function first + if (this.certProvisionFunction) { + try { + logger.log('info', `Attempting custom certificate provision for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' }); + const result = await this.certProvisionFunction(primaryDomain); + + if (result === 'http01') { + logger.log('info', `Custom function returned 'http01', falling back to Let's Encrypt for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' }); + // Continue with existing ACME logic below + } else { + // Use custom certificate + const customCert = result as plugins.tsclass.network.ICert; + + // Convert to internal certificate format + const certData: ICertificateData = { + cert: customCert.cert, + key: customCert.key, + ca: customCert.ca || '', + issueDate: new Date(), + expiryDate: this.extractExpiryDate(customCert.cert), + source: 'custom' + }; + + // Store and apply certificate + await this.certStore.saveCertificate(routeName, certData); + await this.applyCertificate(primaryDomain, certData); + this.updateCertStatus(routeName, 'valid', 'custom', certData); + + logger.log('info', `Custom certificate applied for ${primaryDomain}`, { + domain: primaryDomain, + expiryDate: certData.expiryDate, + component: 'certificate-manager' + }); + return; + } + } catch (error) { + logger.log('error', `Custom cert provision failed for ${primaryDomain}: ${error.message}`, { + domain: primaryDomain, + error: error.message, + component: 'certificate-manager' + }); + // Check if we should fallback to ACME + if (!this.certProvisionFallbackToAcme) { + throw error; + } + logger.log('info', `Falling back to Let's Encrypt for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' }); + } + } + + 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' + ); + } + // Apply renewal threshold from global defaults or route config const renewThreshold = route.action.tls?.acme?.renewBeforeDays || this.globalAcmeDefaults?.renewThresholdDays || @@ -280,7 +350,8 @@ export class SmartCertManager { key: cert.privateKey, ca: cert.publicKey, // Use same as cert for now expiryDate: new Date(cert.validUntil), - issueDate: new Date(cert.created) + issueDate: new Date(cert.created), + source: 'acme' }; await this.certStore.saveCertificate(routeName, certData); @@ -328,7 +399,8 @@ export class SmartCertManager { cert, key, expiryDate: certInfo.validTo, - issueDate: certInfo.validFrom + issueDate: certInfo.validFrom, + source: 'static' }; // Save to store for consistency @@ -399,6 +471,19 @@ export class SmartCertManager { return cert.expiryDate > expiryThreshold; } + /** + * Extract expiry date from a PEM certificate + */ + private extractExpiryDate(_certPem: string): Date { + // For now, we'll default to 90 days for custom certificates + // In production, you might want to use a proper X.509 parser + // or require the custom cert provider to include expiry info + logger.log('info', 'Using default 90-day expiry for custom certificate', { + component: 'certificate-manager' + }); + return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); + } + /** * Add challenge route to SmartProxy diff --git a/ts/proxies/smart-proxy/models/interfaces.ts b/ts/proxies/smart-proxy/models/interfaces.ts index 6464728..e98a2e8 100644 --- a/ts/proxies/smart-proxy/models/interfaces.ts +++ b/ts/proxies/smart-proxy/models/interfaces.ts @@ -135,6 +135,12 @@ export interface ISmartProxyOptions { * or a static certificate object for immediate provisioning. */ certProvisionFunction?: (domain: string) => Promise; + + /** + * Whether to fallback to ACME if custom certificate provision fails. + * Default: true + */ + certProvisionFallbackToAcme?: boolean; } /** diff --git a/ts/proxies/smart-proxy/smart-proxy.ts b/ts/proxies/smart-proxy/smart-proxy.ts index e4d375d..afe5ff3 100644 --- a/ts/proxies/smart-proxy/smart-proxy.ts +++ b/ts/proxies/smart-proxy/smart-proxy.ts @@ -243,6 +243,16 @@ export class SmartProxy extends plugins.EventEmitter { certManager.setGlobalAcmeDefaults(this.settings.acme); } + // Pass down the custom certificate provision function if available + if (this.settings.certProvisionFunction) { + certManager.setCertProvisionFunction(this.settings.certProvisionFunction); + } + + // Pass down the fallback to ACME setting + if (this.settings.certProvisionFallbackToAcme !== undefined) { + certManager.setCertProvisionFallbackToAcme(this.settings.certProvisionFallbackToAcme); + } + await certManager.initialize(); return certManager; }