feat(acme): add DB-backed ACME configuration management and OpsServer certificate settings UI
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
1
ts/acme/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './manager.acme-config.js';
|
||||
182
ts/acme/manager.acme-config.ts
Normal file
182
ts/acme/manager.acme-config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,13 +865,14 @@ 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
|
||||
@@ -860,22 +889,38 @@ export class DcRouter {
|
||||
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'],
|
||||
});
|
||||
|
||||
49
ts/db/documents/classes.acme-config.doc.ts
Normal file
49
ts/db/documents/classes.acme-config.doc.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
94
ts/opsserver/handlers/acme-config.handler.ts
Normal file
94
ts/opsserver/handlers/acme-config.handler.ts
Normal 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 };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,4 @@ export * from './users.handler.js';
|
||||
export * from './dns-provider.handler.js';
|
||||
export * from './domain.handler.js';
|
||||
export * from './dns-record.handler.js';
|
||||
export * from './acme-config.handler.js';
|
||||
25
ts_interfaces/data/acme-config.ts
Normal file
25
ts_interfaces/data/acme-config.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export * from './vpn.js';
|
||||
export * from './dns-provider.js';
|
||||
export * from './domain.js';
|
||||
export * from './dns-record.js';
|
||||
export * from './acme-config.js';
|
||||
@@ -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)
|
||||
|
||||
54
ts_interfaces/requests/acme-config.ts
Normal file
54
ts_interfaces/requests/acme-config.ts
Normal file
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -17,3 +17,4 @@ export * from './users.js';
|
||||
export * from './dns-providers.js';
|
||||
export * from './domains.js';
|
||||
export * from './dns-records.js';
|
||||
export * from './acme-config.js';
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -197,6 +197,28 @@ export const certificateStatePart = await appState.getStatePart<ICertificateStat
|
||||
'soft'
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// ACME Config State (DB-backed singleton, managed via Domains > Certificates)
|
||||
// ============================================================================
|
||||
|
||||
export interface IAcmeConfigState {
|
||||
config: interfaces.data.IAcmeConfig | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
export const acmeConfigStatePart = await appState.getStatePart<IAcmeConfigState>(
|
||||
'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<IAcmeConfigState> => {
|
||||
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<IAcmeConfigState> => {
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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 {
|
||||
<dees-heading level="3">Certificates</dees-heading>
|
||||
|
||||
<div class="certificatesContainer">
|
||||
${this.renderAcmeSettingsCard()}
|
||||
${this.renderStatsTiles(summary)}
|
||||
${this.renderCertificateTable()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAcmeSettingsCard(): TemplateResult {
|
||||
const config = this.acmeState.config;
|
||||
|
||||
if (!config) {
|
||||
return html`
|
||||
<div class="acmeCard acmeCardEmpty">
|
||||
<div class="acmeCardHeader">
|
||||
<span class="acmeCardTitle">ACME Settings</span>
|
||||
<dees-button
|
||||
eventName="edit-acme"
|
||||
@click=${() => this.showEditAcmeDialog()}
|
||||
.type=${'highlighted'}
|
||||
>Configure</dees-button>
|
||||
</div>
|
||||
<p class="acmeEmptyHint">
|
||||
No ACME configuration yet. Click <strong>Configure</strong> to set up automated TLS
|
||||
certificate issuance via Let's Encrypt. You'll also need at least one DNS provider
|
||||
under <strong>Domains > Providers</strong>.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="acmeCard">
|
||||
<div class="acmeCardHeader">
|
||||
<span class="acmeCardTitle">ACME Settings</span>
|
||||
<dees-button eventName="edit-acme" @click=${() => this.showEditAcmeDialog()}>Edit</dees-button>
|
||||
</div>
|
||||
<div class="acmeGrid">
|
||||
<div class="acmeField">
|
||||
<span class="acmeLabel">Account email</span>
|
||||
<span class="acmeValue">${config.accountEmail || '(not set)'}</span>
|
||||
</div>
|
||||
<div class="acmeField">
|
||||
<span class="acmeLabel">Status</span>
|
||||
<span class="acmeValue">
|
||||
<span class="statusBadge ${config.enabled ? 'valid' : 'unknown'}">
|
||||
${config.enabled ? 'enabled' : 'disabled'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="acmeField">
|
||||
<span class="acmeLabel">Mode</span>
|
||||
<span class="acmeValue">
|
||||
<span class="statusBadge ${config.useProduction ? 'valid' : 'provisioning'}">
|
||||
${config.useProduction ? 'production' : 'staging'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="acmeField">
|
||||
<span class="acmeLabel">Auto-renew</span>
|
||||
<span class="acmeValue">${config.autoRenew ? 'on' : 'off'}</span>
|
||||
</div>
|
||||
<div class="acmeField">
|
||||
<span class="acmeLabel">Renewal threshold</span>
|
||||
<span class="acmeValue">${config.renewThresholdDays} days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<dees-form>
|
||||
<dees-input-text
|
||||
.key=${'accountEmail'}
|
||||
.label=${'Account email'}
|
||||
.value=${current?.accountEmail ?? ''}
|
||||
.required=${true}
|
||||
></dees-input-text>
|
||||
<dees-input-checkbox
|
||||
.key=${'enabled'}
|
||||
.label=${'Enabled'}
|
||||
.value=${current?.enabled ?? true}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-checkbox
|
||||
.key=${'useProduction'}
|
||||
.label=${"Use Let's Encrypt production (uncheck for staging)"}
|
||||
.value=${current?.useProduction ?? true}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-checkbox
|
||||
.key=${'autoRenew'}
|
||||
.label=${'Auto-renew certificates'}
|
||||
.value=${current?.autoRenew ?? true}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-text
|
||||
.key=${'renewThresholdDays'}
|
||||
.label=${'Renewal threshold (days)'}
|
||||
.value=${String(current?.renewThresholdDays ?? 30)}
|
||||
></dees-input-text>
|
||||
</dees-form>
|
||||
<p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
|
||||
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.
|
||||
</p>
|
||||
`,
|
||||
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[] = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user