This commit is contained in:
Juergen Kunz
2025-07-13 00:05:32 +00:00
parent 5d206b9800
commit 257a5dc319
4 changed files with 464 additions and 13 deletions

View File

@ -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<string, typeof testCertObject>();
customCerts.set('test.example.com', testCertObject);
// Create proxy with custom certificate provision
testProxy = new SmartProxy({
certProvisionFunction: async (domain: string): Promise<TSmartProxyCertProvisionObject> => {
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<TSmartProxyCertProvisionObject> => {
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<TSmartProxyCertProvisionObject> => {
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<TSmartProxyCertProvisionObject> => {
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<TSmartProxyCertProvisionObject> => {
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();

View File

@ -12,7 +12,7 @@ export interface ICertStatus {
status: 'valid' | 'pending' | 'expired' | 'error'; status: 'valid' | 'pending' | 'expired' | 'error';
expiryDate?: Date; expiryDate?: Date;
issueDate?: Date; issueDate?: Date;
source: 'static' | 'acme'; source: 'static' | 'acme' | 'custom';
error?: string; error?: string;
} }
@ -22,6 +22,7 @@ export interface ICertificateData {
ca?: string; ca?: string;
expiryDate: Date; expiryDate: Date;
issueDate: Date; issueDate: Date;
source?: 'static' | 'acme' | 'custom';
} }
export class SmartCertManager { export class SmartCertManager {
@ -50,6 +51,12 @@ export class SmartCertManager {
// ACME state manager reference // ACME state manager reference
private acmeStateManager: AcmeStateManager | null = null; private acmeStateManager: AcmeStateManager | null = null;
// Custom certificate provision function
private certProvisionFunction?: (domain: string) => Promise<plugins.tsclass.network.ICert | 'http01'>;
// Whether to fallback to ACME if custom provision fails
private certProvisionFallbackToAcme: boolean = true;
constructor( constructor(
private routes: IRouteConfig[], private routes: IRouteConfig[],
private certDir: string = './certs', private certDir: string = './certs',
@ -89,6 +96,20 @@ export class SmartCertManager {
this.globalAcmeDefaults = defaults; this.globalAcmeDefaults = defaults;
} }
/**
* Set custom certificate provision function
*/
public setCertProvisionFunction(fn: (domain: string) => Promise<plugins.tsclass.network.ICert | 'http01'>): 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) * Set callback for updating routes (used for challenge routes)
*/ */
@ -212,15 +233,6 @@ export class SmartCertManager {
route: IRouteConfig, route: IRouteConfig,
domains: string[] domains: string[]
): Promise<void> { ): Promise<void> {
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 primaryDomain = domains[0];
const routeName = route.name || primaryDomain; const routeName = route.name || primaryDomain;
@ -229,10 +241,68 @@ export class SmartCertManager {
if (existingCert && this.isCertificateValid(existingCert)) { if (existingCert && this.isCertificateValid(existingCert)) {
logger.log('info', `Using existing valid certificate for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' }); logger.log('info', `Using existing valid certificate for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
await this.applyCertificate(primaryDomain, existingCert); await this.applyCertificate(primaryDomain, existingCert);
this.updateCertStatus(routeName, 'valid', 'acme', existingCert); this.updateCertStatus(routeName, 'valid', existingCert.source || 'acme', existingCert);
return; 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 // Apply renewal threshold from global defaults or route config
const renewThreshold = route.action.tls?.acme?.renewBeforeDays || const renewThreshold = route.action.tls?.acme?.renewBeforeDays ||
this.globalAcmeDefaults?.renewThresholdDays || this.globalAcmeDefaults?.renewThresholdDays ||
@ -280,7 +350,8 @@ export class SmartCertManager {
key: cert.privateKey, key: cert.privateKey,
ca: cert.publicKey, // Use same as cert for now ca: cert.publicKey, // Use same as cert for now
expiryDate: new Date(cert.validUntil), expiryDate: new Date(cert.validUntil),
issueDate: new Date(cert.created) issueDate: new Date(cert.created),
source: 'acme'
}; };
await this.certStore.saveCertificate(routeName, certData); await this.certStore.saveCertificate(routeName, certData);
@ -328,7 +399,8 @@ export class SmartCertManager {
cert, cert,
key, key,
expiryDate: certInfo.validTo, expiryDate: certInfo.validTo,
issueDate: certInfo.validFrom issueDate: certInfo.validFrom,
source: 'static'
}; };
// Save to store for consistency // Save to store for consistency
@ -399,6 +471,19 @@ export class SmartCertManager {
return cert.expiryDate > expiryThreshold; 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 * Add challenge route to SmartProxy

View File

@ -135,6 +135,12 @@ export interface ISmartProxyOptions {
* or a static certificate object for immediate provisioning. * or a static certificate object for immediate provisioning.
*/ */
certProvisionFunction?: (domain: string) => Promise<TSmartProxyCertProvisionObject>; certProvisionFunction?: (domain: string) => Promise<TSmartProxyCertProvisionObject>;
/**
* Whether to fallback to ACME if custom certificate provision fails.
* Default: true
*/
certProvisionFallbackToAcme?: boolean;
} }
/** /**

View File

@ -243,6 +243,16 @@ export class SmartProxy extends plugins.EventEmitter {
certManager.setGlobalAcmeDefaults(this.settings.acme); 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(); await certManager.initialize();
return certManager; return certManager;
} }