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:
@ -1,6 +1,7 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { NetworkProxy } from '../network-proxy/index.js';
|
||||
import type { IRouteConfig, IRouteTls } from './models/route-types.js';
|
||||
import type { IAcmeOptions } from './models/interfaces.js';
|
||||
import { CertStore } from './cert-store.js';
|
||||
|
||||
export interface ICertStatus {
|
||||
@ -31,6 +32,9 @@ export class SmartCertManager {
|
||||
// Track certificate status by route name
|
||||
private certStatus: Map<string, ICertStatus> = new Map();
|
||||
|
||||
// Global ACME defaults from top-level configuration
|
||||
private globalAcmeDefaults: IAcmeOptions | null = null;
|
||||
|
||||
// Callback to update SmartProxy routes for challenges
|
||||
private updateRoutesCallback?: (routes: IRouteConfig[]) => Promise<void>;
|
||||
|
||||
@ -50,6 +54,13 @@ export class SmartCertManager {
|
||||
this.networkProxy = networkProxy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set global ACME defaults from top-level configuration
|
||||
*/
|
||||
public setGlobalAcmeDefaults(defaults: IAcmeOptions): void {
|
||||
this.globalAcmeDefaults = defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for updating routes (used for challenge routes)
|
||||
*/
|
||||
@ -146,7 +157,12 @@ export class SmartCertManager {
|
||||
domains: string[]
|
||||
): Promise<void> {
|
||||
if (!this.smartAcme) {
|
||||
throw new Error('SmartAcme not initialized');
|
||||
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];
|
||||
@ -161,7 +177,12 @@ export class SmartCertManager {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Requesting ACME certificate for ${domains.join(', ')}`);
|
||||
// Apply renewal threshold from global defaults or route config
|
||||
const renewThreshold = route.action.tls?.acme?.renewBeforeDays ||
|
||||
this.globalAcmeDefaults?.renewThresholdDays ||
|
||||
30;
|
||||
|
||||
console.log(`Requesting ACME certificate for ${domains.join(', ')} (renew ${renewThreshold} days before expiry)`);
|
||||
this.updateCertStatus(routeName, 'pending', 'acme');
|
||||
|
||||
try {
|
||||
@ -303,7 +324,10 @@ export class SmartCertManager {
|
||||
*/
|
||||
private isCertificateValid(cert: ICertificateData): boolean {
|
||||
const now = new Date();
|
||||
const expiryThreshold = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||
|
||||
// Use renewal threshold from global defaults or fallback to 30 days
|
||||
const renewThresholdDays = this.globalAcmeDefaults?.renewThresholdDays || 30;
|
||||
const expiryThreshold = new Date(now.getTime() + renewThresholdDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
return cert.expiryDate > expiryThreshold;
|
||||
}
|
||||
@ -417,12 +441,15 @@ export class SmartCertManager {
|
||||
* Setup challenge handler integration with SmartProxy routing
|
||||
*/
|
||||
private setupChallengeHandler(http01Handler: plugins.smartacme.handlers.Http01MemoryHandler): void {
|
||||
// Use challenge port from global config or default to 80
|
||||
const challengePort = this.globalAcmeDefaults?.port || 80;
|
||||
|
||||
// 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,
|
||||
ports: challengePort,
|
||||
path: '/.well-known/acme-challenge/*'
|
||||
},
|
||||
action: {
|
||||
|
@ -2,15 +2,16 @@ import * as plugins from '../../../plugins.js';
|
||||
// Certificate types removed - define IAcmeOptions locally
|
||||
export interface IAcmeOptions {
|
||||
enabled?: boolean;
|
||||
email?: string;
|
||||
email?: string; // Required when any route uses certificate: 'auto'
|
||||
environment?: 'production' | 'staging';
|
||||
port?: number;
|
||||
useProduction?: boolean;
|
||||
renewThresholdDays?: number;
|
||||
autoRenew?: boolean;
|
||||
certificateStore?: string;
|
||||
accountEmail?: string; // Alias for email
|
||||
port?: number; // Port for HTTP-01 challenges (default: 80)
|
||||
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;
|
||||
renewCheckIntervalHours?: number; // How often to check for renewals (default: 24)
|
||||
routeForwards?: any[];
|
||||
}
|
||||
import type { IRouteConfig } from './route-types.js';
|
||||
@ -97,7 +98,22 @@ export interface ISmartProxyOptions {
|
||||
useNetworkProxy?: number[]; // Array of ports to forward to NetworkProxy
|
||||
networkProxyPort?: number; // Port where NetworkProxy is listening (default: 8443)
|
||||
|
||||
// ACME configuration options for SmartProxy
|
||||
/**
|
||||
* Global ACME configuration options for SmartProxy
|
||||
*
|
||||
* When set, these options will be used as defaults for all routes
|
||||
* with certificate: 'auto' that don't have their own ACME configuration.
|
||||
* Route-specific ACME settings will override these defaults.
|
||||
*
|
||||
* Example:
|
||||
* ```ts
|
||||
* acme: {
|
||||
* email: 'ssl@example.com',
|
||||
* useProduction: false,
|
||||
* port: 80
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
acme?: IAcmeOptions;
|
||||
|
||||
/**
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue
Block a user