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

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.7.1',
version: '13.8.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

1
ts/acme/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './manager.acme-config.js';

View File

@@ -0,0 +1,182 @@
import { logger } from '../logger.js';
import { AcmeConfigDoc } from '../db/documents/index.js';
import type { IDcRouterOptions } from '../classes.dcrouter.js';
import type { IAcmeConfig } from '../../ts_interfaces/data/acme-config.js';
/**
* AcmeConfigManager — owns the singleton ACME configuration in the DB.
*
* Lifecycle:
* - `start()` — loads from the DB; if empty, seeds from legacy constructor
* fields (`tls.contactEmail`, `smartProxyConfig.acme.*`) on first boot.
* - `getConfig()` — returns the in-memory cached `IAcmeConfig` (or null)
* - `updateConfig(args, updatedBy)` — upserts and refreshes the cache
*
* Reload semantics: updates take effect on the next dcrouter restart because
* `SmartAcme` is instantiated once in `setupSmartProxy()`. `renewThresholdDays`
* applies immediately to the next renewal check. See
* `ts_web/elements/domains/ops-view-certificates.ts` for the UI warning.
*/
export class AcmeConfigManager {
private cached: IAcmeConfig | null = null;
constructor(private options: IDcRouterOptions) {}
public async start(): Promise<void> {
logger.log('info', 'AcmeConfigManager: starting');
let doc = await AcmeConfigDoc.load();
if (!doc) {
// First-boot path: seed from legacy constructor fields if present.
const seed = this.deriveSeedFromOptions();
if (seed) {
doc = await this.createSeedDoc(seed);
logger.log(
'info',
`AcmeConfigManager: seeded from constructor legacy fields (accountEmail=${seed.accountEmail}, useProduction=${seed.useProduction})`,
);
} else {
logger.log(
'info',
'AcmeConfigManager: no AcmeConfig in DB and no legacy constructor fields — ACME disabled until configured via Domains > Certificates > Settings.',
);
}
} else if (this.deriveSeedFromOptions()) {
logger.log(
'warn',
'AcmeConfigManager: ignoring constructor tls.contactEmail / smartProxyConfig.acme — DB already has AcmeConfigDoc. Manage via Domains > Certificates > Settings.',
);
}
this.cached = doc ? this.toPlain(doc) : null;
if (this.cached) {
logger.log(
'info',
`AcmeConfigManager: loaded ACME config (accountEmail=${this.cached.accountEmail}, enabled=${this.cached.enabled}, useProduction=${this.cached.useProduction})`,
);
}
}
public async stop(): Promise<void> {
this.cached = null;
}
/**
* Returns the current ACME config, or null if not configured.
* In-memory — does not hit the DB.
*/
public getConfig(): IAcmeConfig | null {
return this.cached;
}
/**
* True if there is an enabled ACME config. Used by `setupSmartProxy()` to
* decide whether to instantiate SmartAcme.
*/
public hasEnabledConfig(): boolean {
return this.cached !== null && this.cached.enabled;
}
/**
* Upsert the ACME config. All fields are optional; missing fields are
* preserved from the existing row (or defaulted if there is no row yet).
*/
public async updateConfig(
args: Partial<Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'>>,
updatedBy: string,
): Promise<IAcmeConfig> {
let doc = await AcmeConfigDoc.load();
const now = Date.now();
if (!doc) {
doc = new AcmeConfigDoc();
doc.configId = 'acme-config';
doc.accountEmail = args.accountEmail ?? '';
doc.enabled = args.enabled ?? true;
doc.useProduction = args.useProduction ?? true;
doc.autoRenew = args.autoRenew ?? true;
doc.renewThresholdDays = args.renewThresholdDays ?? 30;
} else {
if (args.accountEmail !== undefined) doc.accountEmail = args.accountEmail;
if (args.enabled !== undefined) doc.enabled = args.enabled;
if (args.useProduction !== undefined) doc.useProduction = args.useProduction;
if (args.autoRenew !== undefined) doc.autoRenew = args.autoRenew;
if (args.renewThresholdDays !== undefined) doc.renewThresholdDays = args.renewThresholdDays;
}
doc.updatedAt = now;
doc.updatedBy = updatedBy;
await doc.save();
this.cached = this.toPlain(doc);
return this.cached;
}
// ==========================================================================
// Internal helpers
// ==========================================================================
/**
* Build a seed object from the legacy constructor fields. Returns null
* if the user has not provided any of them.
*
* Supports BOTH `tls.contactEmail` (short form) and `smartProxyConfig.acme`
* (full form). `smartProxyConfig.acme` wins when both are present.
*/
private deriveSeedFromOptions(): Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'> | null {
const acme = this.options.smartProxyConfig?.acme;
const tls = this.options.tls;
// Prefer the explicit smartProxyConfig.acme block if present.
if (acme?.accountEmail) {
return {
accountEmail: acme.accountEmail,
enabled: acme.enabled !== false,
useProduction: acme.useProduction !== false,
autoRenew: acme.autoRenew !== false,
renewThresholdDays: acme.renewThresholdDays ?? 30,
};
}
// Fall back to the short tls.contactEmail form.
if (tls?.contactEmail) {
return {
accountEmail: tls.contactEmail,
enabled: true,
useProduction: true,
autoRenew: true,
renewThresholdDays: 30,
};
}
return null;
}
private async createSeedDoc(
seed: Omit<IAcmeConfig, 'updatedAt' | 'updatedBy'>,
): Promise<AcmeConfigDoc> {
const doc = new AcmeConfigDoc();
doc.configId = 'acme-config';
doc.accountEmail = seed.accountEmail;
doc.enabled = seed.enabled;
doc.useProduction = seed.useProduction;
doc.autoRenew = seed.autoRenew;
doc.renewThresholdDays = seed.renewThresholdDays;
doc.updatedAt = Date.now();
doc.updatedBy = 'seed';
await doc.save();
return doc;
}
private toPlain(doc: AcmeConfigDoc): IAcmeConfig {
return {
accountEmail: doc.accountEmail,
enabled: doc.enabled,
useProduction: doc.useProduction,
autoRenew: doc.autoRenew,
renewThresholdDays: doc.renewThresholdDays,
updatedAt: doc.updatedAt,
updatedBy: doc.updatedBy,
};
}
}

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'],
});

View File

@@ -0,0 +1,49 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const getDb = () => DcRouterDb.getInstance().getDb();
/**
* Singleton ACME configuration document. One row per dcrouter instance,
* keyed on the fixed `configId = 'acme-config'` following the
* `VpnServerKeysDoc` pattern.
*
* Replaces the legacy `tls.contactEmail` and `smartProxyConfig.acme.*`
* constructor fields. Managed via the OpsServer UI at
* **Domains > Certificates > Settings**.
*/
@plugins.smartdata.Collection(() => getDb())
export class AcmeConfigDoc extends plugins.smartdata.SmartDataDbDoc<AcmeConfigDoc, AcmeConfigDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public configId: string = 'acme-config';
@plugins.smartdata.svDb()
public accountEmail: string = '';
@plugins.smartdata.svDb()
public enabled: boolean = true;
@plugins.smartdata.svDb()
public useProduction: boolean = true;
@plugins.smartdata.svDb()
public autoRenew: boolean = true;
@plugins.smartdata.svDb()
public renewThresholdDays: number = 30;
@plugins.smartdata.svDb()
public updatedAt: number = 0;
@plugins.smartdata.svDb()
public updatedBy: string = '';
constructor() {
super();
}
public static async load(): Promise<AcmeConfigDoc | null> {
return await AcmeConfigDoc.getInstance({ configId: 'acme-config' });
}
}

View File

@@ -30,3 +30,6 @@ export * from './classes.accounting-session.doc.js';
export * from './classes.dns-provider.doc.js';
export * from './classes.domain.doc.js';
export * from './classes.dns-record.doc.js';
// ACME configuration (singleton)
export * from './classes.acme-config.doc.js';

View File

@@ -36,6 +36,7 @@ export class OpsServer {
private dnsProviderHandler!: handlers.DnsProviderHandler;
private domainHandler!: handlers.DomainHandler;
private dnsRecordHandler!: handlers.DnsRecordHandler;
private acmeConfigHandler!: handlers.AcmeConfigHandler;
constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg;
@@ -102,6 +103,7 @@ export class OpsServer {
this.dnsProviderHandler = new handlers.DnsProviderHandler(this);
this.domainHandler = new handlers.DomainHandler(this);
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
console.log('✅ OpsServer TypedRequest handlers initialized');
}

View File

@@ -0,0 +1,94 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
/**
* CRUD handler for the singleton `AcmeConfigDoc`.
*
* Auth: same dual-mode pattern as other handlers — admin JWT or API token
* with `acme-config:read` / `acme-config:write` scope.
*/
export class AcmeConfigHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private async requireAuth(
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
requiredScope?: interfaces.data.TApiTokenScope,
): Promise<string> {
if (request.identity?.jwt) {
try {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
} catch { /* fall through */ }
}
if (request.apiToken) {
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (tokenManager) {
const token = await tokenManager.validateToken(request.apiToken);
if (token) {
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
return token.createdBy;
}
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
}
}
}
throw new plugins.typedrequest.TypedResponseError('unauthorized');
}
private registerHandlers(): void {
// Get current ACME config
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAcmeConfig>(
'getAcmeConfig',
async (dataArg) => {
await this.requireAuth(dataArg, 'acme-config:read');
const mgr = this.opsServerRef.dcRouterRef.acmeConfigManager;
if (!mgr) return { config: null };
return { config: mgr.getConfig() };
},
),
);
// Update (upsert) the ACME config
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateAcmeConfig>(
'updateAcmeConfig',
async (dataArg) => {
const userId = await this.requireAuth(dataArg, 'acme-config:write');
const mgr = this.opsServerRef.dcRouterRef.acmeConfigManager;
if (!mgr) {
return {
success: false,
message: 'AcmeConfigManager not initialized (DB disabled?)',
};
}
try {
const updated = await mgr.updateConfig(
{
accountEmail: dataArg.accountEmail,
enabled: dataArg.enabled,
useProduction: dataArg.useProduction,
autoRenew: dataArg.autoRenew,
renewThresholdDays: dataArg.renewThresholdDays,
},
userId,
);
return { success: true, config: updated };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
}
}

View File

@@ -16,4 +16,5 @@ export * from './network-target.handler.js';
export * from './users.handler.js';
export * from './dns-provider.handler.js';
export * from './domain.handler.js';
export * from './dns-record.handler.js';
export * from './dns-record.handler.js';
export * from './acme-config.handler.js';