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 { 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 { 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>, updatedBy: string, ): Promise { 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 | 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, ): Promise { 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, }; } }