feat(acme): add DB-backed ACME configuration management and OpsServer certificate settings UI

This commit is contained in:
2026-04-08 13:12:20 +00:00
parent 4fbe01823b
commit c224028495
18 changed files with 793 additions and 33 deletions

View File

@@ -28,6 +28,7 @@ import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, Targe
import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
import { DnsManager } from './dns/manager.dns.js';
import { AcmeConfigManager } from './acme/manager.acme-config.js';
export interface IDcRouterOptions {
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
@@ -276,6 +277,9 @@ export class DcRouter {
// Domain / DNS management (DB-backed providers, domains, records)
public dnsManager?: DnsManager;
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
public acmeConfigManager?: AcmeConfigManager;
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
public detectedPublicIp: string | null = null;
@@ -412,11 +416,35 @@ export class DcRouter {
);
}
// SmartProxy: critical, depends on DcRouterDb + DnsManager (if enabled)
// AcmeConfigManager: optional, depends on DcRouterDb — owns the singleton
// ACME configuration (accountEmail, useProduction, etc.). Must run before
// SmartProxy so setupSmartProxy() can read the ACME config from the DB.
// On first boot, seeds from legacy `tls.contactEmail` / `smartProxyConfig.acme`.
if (this.options.dbConfig?.enabled !== false) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('AcmeConfigManager')
.optional()
.dependsOn('DcRouterDb')
.withStart(async () => {
this.acmeConfigManager = new AcmeConfigManager(this.options);
await this.acmeConfigManager.start();
})
.withStop(async () => {
if (this.acmeConfigManager) {
await this.acmeConfigManager.stop();
this.acmeConfigManager = undefined;
}
})
.withRetry({ maxRetries: 1, baseDelayMs: 500 }),
);
}
// SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled)
const smartProxyDeps: string[] = [];
if (this.options.dbConfig?.enabled !== false) {
smartProxyDeps.push('DcRouterDb');
smartProxyDeps.push('DnsManager');
smartProxyDeps.push('AcmeConfigManager');
}
this.serviceManager.addService(
new plugins.taskbuffer.Service('SmartProxy')
@@ -837,45 +865,62 @@ export class DcRouter {
}
let routes: plugins.smartproxy.IRouteConfig[] = [];
let acmeConfig: plugins.smartproxy.IAcmeOptions | undefined;
// If user provides full SmartProxy config, use it directly
// If user provides full SmartProxy config, use its routes.
// NOTE: `smartProxyConfig.acme` is now seed-only — consumed by
// AcmeConfigManager on first boot. The live ACME config always comes
// from the DB via `this.acmeConfigManager.getConfig()`.
if (this.options.smartProxyConfig) {
routes = this.options.smartProxyConfig.routes || [];
acmeConfig = this.options.smartProxyConfig.acme;
logger.log('info', `Found ${routes.length} routes in config, ACME config present: ${!!acmeConfig}`);
logger.log('info', `Found ${routes.length} routes in config`);
}
// If email config exists, automatically add email routes
if (this.options.emailConfig) {
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) });
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
}
// If DNS is configured, add DNS routes
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
const dnsRoutes = this.generateDnsRoutes();
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) });
routes = [...routes, ...dnsRoutes];
}
// Merge TLS/ACME configuration if provided at root level
if (this.options.tls && !acmeConfig) {
acmeConfig = {
accountEmail: this.options.tls.contactEmail,
enabled: true,
useProduction: true,
autoRenew: true,
renewThresholdDays: 30
};
// Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
// If no config exists or it's disabled, SmartProxy's own ACME is turned off
// and dcrouter's SmartAcme / certProvisionFunction are not wired.
const dbAcme = this.acmeConfigManager?.getConfig();
const acmeConfig: plugins.smartproxy.IAcmeOptions | undefined =
dbAcme && dbAcme.enabled
? {
accountEmail: dbAcme.accountEmail,
enabled: true,
useProduction: dbAcme.useProduction,
autoRenew: dbAcme.autoRenew,
renewThresholdDays: dbAcme.renewThresholdDays,
}
: undefined;
if (acmeConfig) {
logger.log(
'info',
`ACME config: accountEmail=${acmeConfig.accountEmail}, useProduction=${acmeConfig.useProduction}, autoRenew=${acmeConfig.autoRenew}`,
);
} else {
logger.log('info', 'ACME config: disabled or not yet configured in DB');
}
// Configure DNS-01 challenge if any DnsProviderDoc exists in the DB.
// The DnsManager dispatches each challenge to the right provider client
// based on the FQDN being certificated.
// Configure DNS-01 challenge if any DnsProviderDoc exists in the DB AND
// ACME is enabled. The DnsManager dispatches each challenge to the right
// provider client based on the FQDN being certificated.
let challengeHandlers: any[] = [];
if (this.dnsManager && (await this.dnsManager.hasAcmeCapableProvider())) {
if (
acmeConfig &&
this.dnsManager &&
(await this.dnsManager.hasAcmeCapableProvider())
) {
logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (DB providers)');
const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider();
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider);
@@ -977,10 +1022,12 @@ export class DcRouter {
logger.log('error', 'Error stopping old SmartAcme', { error: String(err) })
);
}
// Safe non-null: challengeHandlers.length > 0 implies both dnsManager
// and acmeConfig exist (enforced above).
this.smartAcme = new plugins.smartacme.SmartAcme({
accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com',
accountEmail: dbAcme!.accountEmail,
certManager: new StorageBackedCertManager(),
environment: 'production',
environment: dbAcme!.useProduction ? 'production' : 'integration',
challengeHandlers: challengeHandlers,
challengePriority: ['dns-01'],
});