feat(acme): Improve certificate management by adding global ACME configuration support and allowing route-level overrides. Enhanced error messages help identify missing ACME email and misconfigurations (e.g. wildcard domains). Documentation has been updated and new tests added to verify SmartCertManager behavior, ensuring a clearer migration path from legacy implementations.

This commit is contained in:
2025-05-18 18:29:59 +00:00
parent ac4645dff7
commit 68738137a0
14 changed files with 706 additions and 1472 deletions

View File

@ -115,20 +115,26 @@ export class SmartProxy extends plugins.EventEmitter {
networkProxyPort: settingsArg.networkProxyPort || 8443,
};
// Set default ACME options if not provided
this.settings.acme = this.settings.acme || {};
if (Object.keys(this.settings.acme).length === 0) {
// Normalize ACME options if provided (support both email and accountEmail)
if (this.settings.acme) {
// Support both 'email' and 'accountEmail' fields
if (this.settings.acme.accountEmail && !this.settings.acme.email) {
this.settings.acme.email = this.settings.acme.accountEmail;
}
// Set reasonable defaults for commonly used fields
this.settings.acme = {
enabled: false,
port: 80,
email: 'admin@example.com',
useProduction: false,
renewThresholdDays: 30,
autoRenew: true,
certificateStore: './certs',
skipConfiguredCerts: false,
renewCheckIntervalHours: 24,
routeForwards: []
enabled: this.settings.acme.enabled !== false, // Enable by default if acme object exists
port: this.settings.acme.port || 80,
email: this.settings.acme.email,
useProduction: this.settings.acme.useProduction || false,
renewThresholdDays: this.settings.acme.renewThresholdDays || 30,
autoRenew: this.settings.acme.autoRenew !== false, // Enable by default
certificateStore: this.settings.acme.certificateStore || './certs',
skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false,
renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours || 24,
routeForwards: this.settings.acme.routeForwards || [],
...this.settings.acme // Preserve any additional fields
};
}
@ -186,19 +192,55 @@ export class SmartProxy extends plugins.EventEmitter {
return;
}
// Use the first auto route's ACME config as defaults
const defaultAcme = autoRoutes[0]?.action.tls?.acme;
// Prepare ACME options with priority:
// 1. Use top-level ACME config if available
// 2. Fall back to first auto route's ACME config
// 3. Otherwise use undefined
let acmeOptions: { email?: string; useProduction?: boolean; port?: number } | undefined;
if (this.settings.acme?.email) {
// Use top-level ACME config
acmeOptions = {
email: this.settings.acme.email,
useProduction: this.settings.acme.useProduction || false,
port: this.settings.acme.port || 80
};
console.log(`Using top-level ACME configuration with email: ${acmeOptions.email}`);
} else if (autoRoutes.length > 0) {
// Check for route-level ACME config
const routeWithAcme = autoRoutes.find(r => r.action.tls?.acme?.email);
if (routeWithAcme?.action.tls?.acme) {
const routeAcme = routeWithAcme.action.tls.acme;
acmeOptions = {
email: routeAcme.email,
useProduction: routeAcme.useProduction || false,
port: routeAcme.challengePort || 80
};
console.log(`Using route-level ACME configuration from route '${routeWithAcme.name}' with email: ${acmeOptions.email}`);
}
}
// Validate we have required configuration
if (autoRoutes.length > 0 && !acmeOptions?.email) {
throw new Error(
'ACME email is required for automatic certificate provisioning. ' +
'Please provide email in either:\n' +
'1. Top-level "acme" configuration\n' +
'2. Individual route\'s "tls.acme" configuration'
);
}
this.certManager = new SmartCertManager(
this.settings.routes,
'./certs', // Certificate directory
defaultAcme ? {
email: defaultAcme.email,
useProduction: defaultAcme.useProduction,
port: defaultAcme.challengePort || 80
} : undefined
this.settings.acme?.certificateStore || './certs',
acmeOptions
);
// Pass down the global ACME config to the cert manager
if (this.settings.acme) {
this.certManager.setGlobalAcmeDefaults(this.settings.acme);
}
// Connect with NetworkProxy
if (this.networkProxyBridge.getNetworkProxy()) {
this.certManager.setNetworkProxy(this.networkProxyBridge.getNetworkProxy());
@ -249,9 +291,14 @@ export class SmartProxy extends plugins.EventEmitter {
// Validate the route configuration
const configWarnings = this.routeManager.validateConfiguration();
if (configWarnings.length > 0) {
console.log("Route configuration warnings:");
for (const warning of configWarnings) {
// Also validate ACME configuration
const acmeWarnings = this.validateAcmeConfiguration();
const allWarnings = [...configWarnings, ...acmeWarnings];
if (allWarnings.length > 0) {
console.log("Configuration warnings:");
for (const warning of allWarnings) {
console.log(` - ${warning}`);
}
}
@ -663,5 +710,76 @@ export class SmartProxy extends plugins.EventEmitter {
public async getNfTablesStatus(): Promise<Record<string, any>> {
return this.nftablesManager.getStatus();
}
/**
* Validate ACME configuration
*/
private validateAcmeConfiguration(): string[] {
const warnings: string[] = [];
// Check for routes with certificate: 'auto'
const autoRoutes = this.settings.routes.filter(r =>
r.action.tls?.certificate === 'auto'
);
if (autoRoutes.length === 0) {
return warnings;
}
// Check if we have ACME email configuration
const hasTopLevelEmail = this.settings.acme?.email;
const routesWithEmail = autoRoutes.filter(r => r.action.tls?.acme?.email);
if (!hasTopLevelEmail && routesWithEmail.length === 0) {
warnings.push(
'Routes with certificate: "auto" require ACME email configuration. ' +
'Add email to either top-level "acme" config or individual route\'s "tls.acme" config.'
);
}
// Check for port 80 availability for challenges
if (autoRoutes.length > 0) {
const challengePort = this.settings.acme?.port || 80;
const portsInUse = this.routeManager.getListeningPorts();
if (!portsInUse.includes(challengePort)) {
warnings.push(
`Port ${challengePort} is not configured for any routes but is needed for ACME challenges. ` +
`Add a route listening on port ${challengePort} or ensure it's accessible for HTTP-01 challenges.`
);
}
}
// Check for mismatched environments
if (this.settings.acme?.useProduction) {
const stagingRoutes = autoRoutes.filter(r =>
r.action.tls?.acme?.useProduction === false
);
if (stagingRoutes.length > 0) {
warnings.push(
'Top-level ACME uses production but some routes use staging. ' +
'Consider aligning environments to avoid certificate issues.'
);
}
}
// Check for wildcard domains with auto certificates
for (const route of autoRoutes) {
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
const wildcardDomains = domains.filter(d => d?.includes('*'));
if (wildcardDomains.length > 0) {
warnings.push(
`Route "${route.name}" has wildcard domain(s) ${wildcardDomains.join(', ')} ` +
'with certificate: "auto". Wildcard certificates require DNS-01 challenges, ' +
'which are not currently supported. Use static certificates instead.'
);
}
}
return warnings;
}
}