diff --git a/changelog.md b/changelog.md index 504a2bc..fdb4022 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2026-04-08 - 13.8.0 - feat(acme) +add DB-backed ACME configuration management and OpsServer certificate settings UI + +- introduces a singleton AcmeConfig manager and document persisted in the database, with first-boot seeding from legacy tls.contactEmail and smartProxyConfig.acme options +- updates SmartProxy startup to read live ACME settings from the database and only enable DNS-01 challenge wiring when ACME is configured and enabled +- adds authenticated OpsServer typed request endpoints and API token scopes for reading and updating ACME configuration +- adds web app state and a certificates view card/modal for viewing and editing ACME settings from the Domains certificate UI + ## 2026-04-08 - 13.7.1 - fix(repo) no changes to commit diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 5c81af4..1568d6e 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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.' } diff --git a/ts/acme/index.ts b/ts/acme/index.ts new file mode 100644 index 0000000..e27887a --- /dev/null +++ b/ts/acme/index.ts @@ -0,0 +1 @@ +export * from './manager.acme-config.js'; diff --git a/ts/acme/manager.acme-config.ts b/ts/acme/manager.acme-config.ts new file mode 100644 index 0000000..469dd3e --- /dev/null +++ b/ts/acme/manager.acme-config.ts @@ -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 { + 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, + }; + } +} diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index eb5f213..a9b834c 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -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'], }); diff --git a/ts/db/documents/classes.acme-config.doc.ts b/ts/db/documents/classes.acme-config.doc.ts new file mode 100644 index 0000000..a610673 --- /dev/null +++ b/ts/db/documents/classes.acme-config.doc.ts @@ -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 { + @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 { + return await AcmeConfigDoc.getInstance({ configId: 'acme-config' }); + } +} diff --git a/ts/db/documents/index.ts b/ts/db/documents/index.ts index f44fe58..17809dd 100644 --- a/ts/db/documents/index.ts +++ b/ts/db/documents/index.ts @@ -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'; diff --git a/ts/opsserver/classes.opsserver.ts b/ts/opsserver/classes.opsserver.ts index 9c62af6..92f875e 100644 --- a/ts/opsserver/classes.opsserver.ts +++ b/ts/opsserver/classes.opsserver.ts @@ -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'); } diff --git a/ts/opsserver/handlers/acme-config.handler.ts b/ts/opsserver/handlers/acme-config.handler.ts new file mode 100644 index 0000000..229b42d --- /dev/null +++ b/ts/opsserver/handlers/acme-config.handler.ts @@ -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 { + 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( + '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( + '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 }; + } + }, + ), + ); + } +} diff --git a/ts/opsserver/handlers/index.ts b/ts/opsserver/handlers/index.ts index 2af6f96..10232fd 100644 --- a/ts/opsserver/handlers/index.ts +++ b/ts/opsserver/handlers/index.ts @@ -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'; \ No newline at end of file +export * from './dns-record.handler.js'; +export * from './acme-config.handler.js'; \ No newline at end of file diff --git a/ts_interfaces/data/acme-config.ts b/ts_interfaces/data/acme-config.ts new file mode 100644 index 0000000..4550060 --- /dev/null +++ b/ts_interfaces/data/acme-config.ts @@ -0,0 +1,25 @@ +/** + * ACME configuration for automated TLS certificate issuance via Let's Encrypt. + * + * Persisted as a singleton `AcmeConfigDoc` in the DcRouterDb. Replaces the + * legacy constructor fields `tls.contactEmail` / `smartProxyConfig.acme.*` + * which are now seed-only (used once on first boot if the DB is empty). + * + * Managed via the OpsServer UI at **Domains > Certificates > Settings**. + */ +export interface IAcmeConfig { + /** Contact email used for Let's Encrypt account registration. */ + accountEmail: string; + /** Whether ACME is enabled. If false, no certs are issued via ACME. */ + enabled: boolean; + /** True = Let's Encrypt production, false = staging. */ + useProduction: boolean; + /** Whether to automatically renew certs before expiry. */ + autoRenew: boolean; + /** Renew when a cert has fewer than this many days of validity left. */ + renewThresholdDays: number; + /** Unix ms timestamp of last config change. */ + updatedAt: number; + /** Who last updated the config (userId or 'seed' / 'system'). */ + updatedBy: string; +} diff --git a/ts_interfaces/data/index.ts b/ts_interfaces/data/index.ts index 447246a..cbd9a7f 100644 --- a/ts_interfaces/data/index.ts +++ b/ts_interfaces/data/index.ts @@ -6,4 +6,5 @@ export * from './target-profile.js'; export * from './vpn.js'; export * from './dns-provider.js'; export * from './domain.js'; -export * from './dns-record.js'; \ No newline at end of file +export * from './dns-record.js'; +export * from './acme-config.js'; \ No newline at end of file diff --git a/ts_interfaces/data/route-management.ts b/ts_interfaces/data/route-management.ts index 05fec24..4d6f178 100644 --- a/ts_interfaces/data/route-management.ts +++ b/ts_interfaces/data/route-management.ts @@ -17,7 +17,8 @@ export type TApiTokenScope = | 'targets:read' | 'targets:write' | 'dns-providers:read' | 'dns-providers:write' | 'domains:read' | 'domains:write' - | 'dns-records:read' | 'dns-records:write'; + | 'dns-records:read' | 'dns-records:write' + | 'acme-config:read' | 'acme-config:write'; // ============================================================================ // Source Profile Types (source-side: who can access) diff --git a/ts_interfaces/requests/acme-config.ts b/ts_interfaces/requests/acme-config.ts new file mode 100644 index 0000000..d33d0bc --- /dev/null +++ b/ts_interfaces/requests/acme-config.ts @@ -0,0 +1,54 @@ +import * as plugins from '../plugins.js'; +import type * as authInterfaces from '../data/auth.js'; +import type { IAcmeConfig } from '../data/acme-config.js'; + +// ============================================================================ +// ACME Config Endpoints +// ============================================================================ + +/** + * Get the current ACME configuration. Returns null if no config has been + * set yet (neither from DB nor seeded from the constructor). + */ +export interface IReq_GetAcmeConfig extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_GetAcmeConfig +> { + method: 'getAcmeConfig'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + }; + response: { + config: IAcmeConfig | null; + }; +} + +/** + * Update the ACME configuration (upsert). All fields are required on first + * create, optional on subsequent updates (partial update). + * + * NOTE: Most fields take effect on the next dcrouter restart — SmartAcme is + * instantiated once at startup. `renewThresholdDays` applies immediately to + * the next renewal check. + */ +export interface IReq_UpdateAcmeConfig extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_UpdateAcmeConfig +> { + method: 'updateAcmeConfig'; + request: { + identity?: authInterfaces.IIdentity; + apiToken?: string; + accountEmail?: string; + enabled?: boolean; + useProduction?: boolean; + autoRenew?: boolean; + renewThresholdDays?: number; + }; + response: { + success: boolean; + config?: IAcmeConfig; + message?: string; + }; +} diff --git a/ts_interfaces/requests/index.ts b/ts_interfaces/requests/index.ts index d66e2ff..da0c28d 100644 --- a/ts_interfaces/requests/index.ts +++ b/ts_interfaces/requests/index.ts @@ -16,4 +16,5 @@ export * from './network-targets.js'; export * from './users.js'; export * from './dns-providers.js'; export * from './domains.js'; -export * from './dns-records.js'; \ No newline at end of file +export * from './dns-records.js'; +export * from './acme-config.js'; \ No newline at end of file diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 5c81af4..1568d6e 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -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.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 9578766..2be0122 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -197,6 +197,28 @@ export const certificateStatePart = await appState.getStatePart Certificates) +// ============================================================================ + +export interface IAcmeConfigState { + config: interfaces.data.IAcmeConfig | null; + isLoading: boolean; + error: string | null; + lastUpdated: number; +} + +export const acmeConfigStatePart = await appState.getStatePart( + 'acmeConfig', + { + config: null, + isLoading: false, + error: null, + lastUpdated: 0, + }, + 'soft', +); + // ============================================================================ // Remote Ingress State // ============================================================================ @@ -1953,6 +1975,72 @@ export const deleteDnsRecordAction = domainsStatePart.createAction<{ id: string; }, ); +// ============================================================================ +// ACME Config Actions +// ============================================================================ + +export const fetchAcmeConfigAction = acmeConfigStatePart.createAction( + async (statePartArg): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + if (!context.identity) return currentState; + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_GetAcmeConfig + >('/typedrequest', 'getAcmeConfig'); + const response = await request.fire({ identity: context.identity }); + return { + config: response.config, + isLoading: false, + error: null, + lastUpdated: Date.now(), + }; + } catch (error: unknown) { + return { + ...currentState, + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to fetch ACME config', + }; + } + }, +); + +export const updateAcmeConfigAction = acmeConfigStatePart.createAction<{ + accountEmail?: string; + enabled?: boolean; + useProduction?: boolean; + autoRenew?: boolean; + renewThresholdDays?: number; +}>(async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_UpdateAcmeConfig + >('/typedrequest', 'updateAcmeConfig'); + const response = await request.fire({ + identity: context.identity!, + accountEmail: dataArg.accountEmail, + enabled: dataArg.enabled, + useProduction: dataArg.useProduction, + autoRenew: dataArg.autoRenew, + renewThresholdDays: dataArg.renewThresholdDays, + }); + if (!response.success) { + return { + ...statePartArg.getState()!, + error: response.message || 'Failed to update ACME config', + }; + } + return await actionContext!.dispatch(fetchAcmeConfigAction, null); + } catch (error: unknown) { + return { + ...statePartArg.getState()!, + error: error instanceof Error ? error.message : 'Failed to update ACME config', + }; + } +}); + // ============================================================================ // Route Management Actions // ============================================================================ diff --git a/ts_web/elements/domains/ops-view-certificates.ts b/ts_web/elements/domains/ops-view-certificates.ts index 12642f2..5a1d1ad 100644 --- a/ts_web/elements/domains/ops-view-certificates.ts +++ b/ts_web/elements/domains/ops-view-certificates.ts @@ -23,17 +23,25 @@ export class OpsViewCertificates extends DeesElement { @state() accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState()!; + @state() + accessor acmeState: appstate.IAcmeConfigState = appstate.acmeConfigStatePart.getState()!; + constructor() { super(); - const sub = appstate.certificateStatePart.select().subscribe((newState) => { + const certSub = appstate.certificateStatePart.select().subscribe((newState) => { this.certState = newState; }); - this.rxSubscriptions.push(sub); + this.rxSubscriptions.push(certSub); + const acmeSub = appstate.acmeConfigStatePart.select().subscribe((newState) => { + this.acmeState = newState; + }); + this.rxSubscriptions.push(acmeSub); } async connectedCallback() { await super.connectedCallback(); await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null); + await appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null); } public static styles = [ @@ -46,6 +54,62 @@ export class OpsViewCertificates extends DeesElement { gap: 24px; } + .acmeCard { + padding: 16px 20px; + background: ${cssManager.bdTheme('#f9fafb', '#111827')}; + border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')}; + border-radius: 8px; + } + + .acmeCard.acmeCardEmpty { + background: ${cssManager.bdTheme('#fffbeb', '#1c1917')}; + border-color: ${cssManager.bdTheme('#fde68a', '#78350f')}; + } + + .acmeCardHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + + .acmeCardTitle { + font-size: 14px; + font-weight: 600; + color: ${cssManager.bdTheme('#111827', '#f3f4f6')}; + } + + .acmeGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px 24px; + } + + .acmeField { + display: flex; + flex-direction: column; + gap: 2px; + } + + .acmeLabel { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.03em; + color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; + } + + .acmeValue { + font-size: 13px; + color: ${cssManager.bdTheme('#111827', '#f3f4f6')}; + } + + .acmeEmptyHint { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: ${cssManager.bdTheme('#78350f', '#fde68a')}; + } + .statusBadge { display: inline-flex; align-items: center; @@ -162,12 +226,151 @@ export class OpsViewCertificates extends DeesElement { Certificates
+ ${this.renderAcmeSettingsCard()} ${this.renderStatsTiles(summary)} ${this.renderCertificateTable()}
`; } + private renderAcmeSettingsCard(): TemplateResult { + const config = this.acmeState.config; + + if (!config) { + return html` +
+
+ ACME Settings + this.showEditAcmeDialog()} + .type=${'highlighted'} + >Configure +
+

+ No ACME configuration yet. Click Configure to set up automated TLS + certificate issuance via Let's Encrypt. You'll also need at least one DNS provider + under Domains > Providers. +

+
+ `; + } + + return html` +
+
+ ACME Settings + this.showEditAcmeDialog()}>Edit +
+
+
+ Account email + ${config.accountEmail || '(not set)'} +
+
+ Status + + + ${config.enabled ? 'enabled' : 'disabled'} + + +
+
+ Mode + + + ${config.useProduction ? 'production' : 'staging'} + + +
+
+ Auto-renew + ${config.autoRenew ? 'on' : 'off'} +
+
+ Renewal threshold + ${config.renewThresholdDays} days +
+
+
+ `; + } + + private async showEditAcmeDialog() { + const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog'); + const current = this.acmeState.config; + + DeesModal.createAndShow({ + heading: current ? 'Edit ACME Settings' : 'Configure ACME', + content: html` + + + + + + + +

+ Most fields take effect on the next dcrouter restart (SmartAcme is instantiated once at + startup). Changing the account email creates a new Let's Encrypt account — only do this + if you know what you're doing. +

+ `, + menuOptions: [ + { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, + { + name: 'Save', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot + ?.querySelector('.content') + ?.querySelector('dees-form'); + if (!form) return; + const data = await form.collectFormData(); + const email = String(data.accountEmail ?? '').trim(); + if (!email) { + DeesToast.show({ + message: 'Account email is required', + type: 'warning', + duration: 2500, + }); + return; + } + const threshold = parseInt(String(data.renewThresholdDays ?? '30'), 10); + await appstate.acmeConfigStatePart.dispatchAction(appstate.updateAcmeConfigAction, { + accountEmail: email, + enabled: Boolean(data.enabled), + useProduction: Boolean(data.useProduction), + autoRenew: Boolean(data.autoRenew), + renewThresholdDays: Number.isFinite(threshold) ? threshold : 30, + }); + modalArg.destroy(); + }, + }, + ], + }); + } + private renderStatsTiles(summary: appstate.ICertificateState['summary']): TemplateResult { const tiles: IStatsTile[] = [ {