BREAKING CHANGE(smart-proxy): move certificate persistence to an in-memory store and introduce consumer-managed certStore API; add default self-signed fallback cert and change ACME account handling
This commit is contained in:
@@ -2,6 +2,6 @@
|
||||
* SmartProxy models
|
||||
*/
|
||||
// Export everything except IAcmeOptions from interfaces
|
||||
export type { ISmartProxyOptions, IConnectionRecord, TSmartProxyCertProvisionObject } from './interfaces.js';
|
||||
export type { ISmartProxyOptions, ISmartProxyCertStore, IConnectionRecord, TSmartProxyCertProvisionObject } from './interfaces.js';
|
||||
export * from './route-types.js';
|
||||
export * from './metrics-types.js';
|
||||
|
||||
@@ -10,11 +10,23 @@ export interface IAcmeOptions {
|
||||
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; // How often to check for renewals (default: 24)
|
||||
routeForwards?: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumer-provided certificate storage.
|
||||
* SmartProxy never writes certs to disk — the consumer owns all persistence.
|
||||
*/
|
||||
export interface ISmartProxyCertStore {
|
||||
/** Load all stored certs on startup (called once before cert provisioning) */
|
||||
loadAll: () => Promise<Array<{ domain: string; publicKey: string; privateKey: string; ca?: string }>>;
|
||||
/** Save a cert after successful provisioning */
|
||||
save: (domain: string, publicKey: string, privateKey: string, ca?: string) => Promise<void>;
|
||||
/** Remove a cert (optional) */
|
||||
remove?: (domain: string) => Promise<void>;
|
||||
}
|
||||
import type { IRouteConfig } from './route-types.js';
|
||||
|
||||
/**
|
||||
@@ -136,6 +148,20 @@ export interface ISmartProxyOptions {
|
||||
*/
|
||||
certProvisionFallbackToAcme?: boolean;
|
||||
|
||||
/**
|
||||
* Disable the default self-signed fallback certificate.
|
||||
* When false (default), a self-signed cert is generated at startup and loaded
|
||||
* as '*' so TLS handshakes never fail due to missing certs.
|
||||
*/
|
||||
disableDefaultCert?: boolean;
|
||||
|
||||
/**
|
||||
* Consumer-provided cert storage. SmartProxy never writes certs to disk.
|
||||
* On startup, loadAll() is called to pre-load persisted certs.
|
||||
* After each successful cert provision, save() is called.
|
||||
*/
|
||||
certStore?: ISmartProxyCertStore;
|
||||
|
||||
/**
|
||||
* Path to the RustProxy binary. If not set, the binary is located
|
||||
* automatically via env var, platform package, local build, or PATH.
|
||||
|
||||
@@ -10,6 +10,7 @@ import { RustMetricsAdapter } from './rust-metrics-adapter.js';
|
||||
// Route management
|
||||
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
|
||||
import { RouteValidator } from './utils/route-validator.js';
|
||||
import { generateDefaultCertificate } from './utils/default-cert-generator.js';
|
||||
import { Mutex } from './utils/mutex.js';
|
||||
|
||||
// Types
|
||||
@@ -68,7 +69,6 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
useProduction: this.settings.acme.useProduction || false,
|
||||
renewThresholdDays: this.settings.acme.renewThresholdDays || 30,
|
||||
autoRenew: this.settings.acme.autoRenew !== false,
|
||||
certificateStore: this.settings.acme.certificateStore || './certs',
|
||||
skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false,
|
||||
renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours || 24,
|
||||
routeForwards: this.settings.acme.routeForwards || [],
|
||||
@@ -165,8 +165,34 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath());
|
||||
}
|
||||
|
||||
// Load default self-signed fallback certificate (domain: '*')
|
||||
if (!this.settings.disableDefaultCert) {
|
||||
try {
|
||||
const defaultCert = generateDefaultCertificate();
|
||||
await this.bridge.loadCertificate('*', defaultCert.cert, defaultCert.key);
|
||||
logger.log('info', 'Default self-signed fallback certificate loaded', { component: 'smart-proxy' });
|
||||
} catch (err: any) {
|
||||
logger.log('warn', `Failed to generate default certificate: ${err.message}`, { component: 'smart-proxy' });
|
||||
}
|
||||
}
|
||||
|
||||
// Load consumer-stored certificates
|
||||
const preloadedDomains = new Set<string>();
|
||||
if (this.settings.certStore) {
|
||||
try {
|
||||
const stored = await this.settings.certStore.loadAll();
|
||||
for (const entry of stored) {
|
||||
await this.bridge.loadCertificate(entry.domain, entry.publicKey, entry.privateKey, entry.ca);
|
||||
preloadedDomains.add(entry.domain);
|
||||
}
|
||||
logger.log('info', `Loaded ${stored.length} certificate(s) from consumer store`, { component: 'smart-proxy' });
|
||||
} catch (err: any) {
|
||||
logger.log('warn', `Failed to load certificates from consumer store: ${err.message}`, { component: 'smart-proxy' });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle certProvisionFunction
|
||||
await this.provisionCertificatesViaCallback();
|
||||
await this.provisionCertificatesViaCallback(preloadedDomains);
|
||||
|
||||
// Start metrics polling
|
||||
this.metricsAdapter.startPolling();
|
||||
@@ -355,7 +381,6 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
port: acme.port,
|
||||
renewThresholdDays: acme.renewThresholdDays,
|
||||
autoRenew: acme.autoRenew,
|
||||
certificateStore: acme.certificateStore,
|
||||
renewCheckIntervalHours: acme.renewCheckIntervalHours,
|
||||
}
|
||||
: undefined,
|
||||
@@ -379,11 +404,11 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
* If the callback returns a cert object, load it into Rust.
|
||||
* If it returns 'http01', let Rust handle ACME.
|
||||
*/
|
||||
private async provisionCertificatesViaCallback(): Promise<void> {
|
||||
private async provisionCertificatesViaCallback(skipDomains: Set<string> = new Set()): Promise<void> {
|
||||
const provisionFn = this.settings.certProvisionFunction;
|
||||
if (!provisionFn) return;
|
||||
|
||||
const provisionedDomains = new Set<string>();
|
||||
const provisionedDomains = new Set<string>(skipDomains);
|
||||
|
||||
for (const route of this.settings.routes) {
|
||||
if (route.action.tls?.certificate !== 'auto') continue;
|
||||
@@ -405,7 +430,8 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
await this.bridge.provisionCertificate(route.name);
|
||||
logger.log('info', `Triggered Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' });
|
||||
} catch (provisionErr: any) {
|
||||
logger.log('warn', `Cannot provision cert for ${domain} — callback returned 'http01' but Rust ACME failed: ${provisionErr.message}`, { component: 'smart-proxy' });
|
||||
logger.log('warn', `Cannot provision cert for ${domain} — callback returned 'http01' but Rust ACME failed: ${provisionErr.message}. ` +
|
||||
'Note: Rust ACME is disabled when certProvisionFunction is set.', { component: 'smart-proxy' });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -420,13 +446,30 @@ export class SmartProxy extends plugins.EventEmitter {
|
||||
certObj.privateKey,
|
||||
);
|
||||
logger.log('info', `Certificate loaded via provision function for ${domain}`, { component: 'smart-proxy' });
|
||||
|
||||
// Persist to consumer store
|
||||
if (this.settings.certStore?.save) {
|
||||
try {
|
||||
await this.settings.certStore.save(domain, certObj.publicKey, certObj.privateKey);
|
||||
} catch (storeErr: any) {
|
||||
logger.log('warn', `certStore.save() failed for ${domain}: ${storeErr.message}`, { component: 'smart-proxy' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.log('warn', `certProvisionFunction failed for ${domain}: ${err.message}`, { component: 'smart-proxy' });
|
||||
|
||||
// Fallback to ACME if enabled
|
||||
if (this.settings.certProvisionFallbackToAcme !== false) {
|
||||
logger.log('info', `Falling back to ACME for ${domain}`, { component: 'smart-proxy' });
|
||||
// Fallback to ACME if enabled and route has a name
|
||||
if (this.settings.certProvisionFallbackToAcme !== false && route.name) {
|
||||
try {
|
||||
await this.bridge.provisionCertificate(route.name);
|
||||
logger.log('info', `Falling back to Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' });
|
||||
} catch (acmeErr: any) {
|
||||
logger.log('warn', `ACME fallback also failed for ${domain}: ${acmeErr.message}` +
|
||||
(this.settings.disableDefaultCert
|
||||
? ' — TLS will fail for this domain (disableDefaultCert is true)'
|
||||
: ' — default self-signed fallback cert will be used'), { component: 'smart-proxy' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
36
ts/proxies/smart-proxy/utils/default-cert-generator.ts
Normal file
36
ts/proxies/smart-proxy/utils/default-cert-generator.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
|
||||
/**
|
||||
* Generate a self-signed fallback certificate (CN=SmartProxy Default Certificate, SAN=*).
|
||||
* Used as the '*' wildcard fallback so TLS handshakes never reset due to missing certs.
|
||||
*/
|
||||
export function generateDefaultCertificate(): { cert: string; key: string } {
|
||||
const forge = plugins.smartcrypto.nodeForge;
|
||||
|
||||
// Generate 2048-bit RSA keypair
|
||||
const keypair = forge.pki.rsa.generateKeyPair({ bits: 2048 });
|
||||
|
||||
// Create self-signed X.509 certificate
|
||||
const cert = forge.pki.createCertificate();
|
||||
cert.publicKey = keypair.publicKey;
|
||||
cert.serialNumber = '01';
|
||||
cert.validity.notBefore = new Date();
|
||||
cert.validity.notAfter = new Date();
|
||||
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
|
||||
|
||||
const attrs = [{ name: 'commonName', value: 'SmartProxy Default Certificate' }];
|
||||
cert.setSubject(attrs);
|
||||
cert.setIssuer(attrs);
|
||||
|
||||
// Add wildcard SAN
|
||||
cert.setExtensions([
|
||||
{ name: 'subjectAltName', altNames: [{ type: 2 /* DNS */, value: '*' }] },
|
||||
]);
|
||||
|
||||
cert.sign(keypair.privateKey, forge.md.sha256.create());
|
||||
|
||||
return {
|
||||
cert: forge.pki.certificateToPem(cert),
|
||||
key: forge.pki.privateKeyToPem(keypair.privateKey),
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,9 @@ export * from './route-validator.js';
|
||||
// Export route utilities for route operations
|
||||
export * from './route-utils.js';
|
||||
|
||||
// Export default certificate generator
|
||||
export { generateDefaultCertificate } from './default-cert-generator.js';
|
||||
|
||||
// Export additional functions from route-helpers that weren't already exported
|
||||
export {
|
||||
createApiGatewayRoute,
|
||||
|
||||
Reference in New Issue
Block a user