feat(acme): add DB-backed ACME configuration management and OpsServer certificate settings UI
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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)
|
## 2026-04-08 - 13.7.1 - fix(repo)
|
||||||
no changes to commit
|
no changes to commit
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.7.1',
|
version: '13.8.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
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 { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
|
||||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||||
import { DnsManager } from './dns/manager.dns.js';
|
import { DnsManager } from './dns/manager.dns.js';
|
||||||
|
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
||||||
|
|
||||||
export interface IDcRouterOptions {
|
export interface IDcRouterOptions {
|
||||||
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
|
/** 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)
|
// Domain / DNS management (DB-backed providers, domains, records)
|
||||||
public dnsManager?: DnsManager;
|
public dnsManager?: DnsManager;
|
||||||
|
|
||||||
|
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
|
||||||
|
public acmeConfigManager?: AcmeConfigManager;
|
||||||
|
|
||||||
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
|
||||||
public detectedPublicIp: string | null = null;
|
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[] = [];
|
const smartProxyDeps: string[] = [];
|
||||||
if (this.options.dbConfig?.enabled !== false) {
|
if (this.options.dbConfig?.enabled !== false) {
|
||||||
smartProxyDeps.push('DcRouterDb');
|
smartProxyDeps.push('DcRouterDb');
|
||||||
smartProxyDeps.push('DnsManager');
|
smartProxyDeps.push('DnsManager');
|
||||||
|
smartProxyDeps.push('AcmeConfigManager');
|
||||||
}
|
}
|
||||||
this.serviceManager.addService(
|
this.serviceManager.addService(
|
||||||
new plugins.taskbuffer.Service('SmartProxy')
|
new plugins.taskbuffer.Service('SmartProxy')
|
||||||
@@ -837,45 +865,62 @@ export class DcRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let routes: plugins.smartproxy.IRouteConfig[] = [];
|
let routes: plugins.smartproxy.IRouteConfig[] = [];
|
||||||
let acmeConfig: plugins.smartproxy.IAcmeOptions | undefined;
|
|
||||||
|
// If user provides full SmartProxy config, use its routes.
|
||||||
// If user provides full SmartProxy config, use it directly
|
// 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) {
|
if (this.options.smartProxyConfig) {
|
||||||
routes = this.options.smartProxyConfig.routes || [];
|
routes = this.options.smartProxyConfig.routes || [];
|
||||||
acmeConfig = this.options.smartProxyConfig.acme;
|
logger.log('info', `Found ${routes.length} routes in config`);
|
||||||
logger.log('info', `Found ${routes.length} routes in config, ACME config present: ${!!acmeConfig}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If email config exists, automatically add email routes
|
// If email config exists, automatically add email routes
|
||||||
if (this.options.emailConfig) {
|
if (this.options.emailConfig) {
|
||||||
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
|
const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
|
||||||
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) });
|
logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) });
|
||||||
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
|
routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
|
||||||
}
|
}
|
||||||
|
|
||||||
// If DNS is configured, add DNS routes
|
// If DNS is configured, add DNS routes
|
||||||
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
|
if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
|
||||||
const dnsRoutes = this.generateDnsRoutes();
|
const dnsRoutes = this.generateDnsRoutes();
|
||||||
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) });
|
logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) });
|
||||||
routes = [...routes, ...dnsRoutes];
|
routes = [...routes, ...dnsRoutes];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge TLS/ACME configuration if provided at root level
|
// Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
|
||||||
if (this.options.tls && !acmeConfig) {
|
// If no config exists or it's disabled, SmartProxy's own ACME is turned off
|
||||||
acmeConfig = {
|
// and dcrouter's SmartAcme / certProvisionFunction are not wired.
|
||||||
accountEmail: this.options.tls.contactEmail,
|
const dbAcme = this.acmeConfigManager?.getConfig();
|
||||||
enabled: true,
|
const acmeConfig: plugins.smartproxy.IAcmeOptions | undefined =
|
||||||
useProduction: true,
|
dbAcme && dbAcme.enabled
|
||||||
autoRenew: true,
|
? {
|
||||||
renewThresholdDays: 30
|
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.
|
// Configure DNS-01 challenge if any DnsProviderDoc exists in the DB AND
|
||||||
// The DnsManager dispatches each challenge to the right provider client
|
// ACME is enabled. The DnsManager dispatches each challenge to the right
|
||||||
// based on the FQDN being certificated.
|
// provider client based on the FQDN being certificated.
|
||||||
let challengeHandlers: any[] = [];
|
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)');
|
logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (DB providers)');
|
||||||
const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider();
|
const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider();
|
||||||
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider);
|
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) })
|
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({
|
this.smartAcme = new plugins.smartacme.SmartAcme({
|
||||||
accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com',
|
accountEmail: dbAcme!.accountEmail,
|
||||||
certManager: new StorageBackedCertManager(),
|
certManager: new StorageBackedCertManager(),
|
||||||
environment: 'production',
|
environment: dbAcme!.useProduction ? 'production' : 'integration',
|
||||||
challengeHandlers: challengeHandlers,
|
challengeHandlers: challengeHandlers,
|
||||||
challengePriority: ['dns-01'],
|
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.dns-provider.doc.js';
|
||||||
export * from './classes.domain.doc.js';
|
export * from './classes.domain.doc.js';
|
||||||
export * from './classes.dns-record.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 dnsProviderHandler!: handlers.DnsProviderHandler;
|
||||||
private domainHandler!: handlers.DomainHandler;
|
private domainHandler!: handlers.DomainHandler;
|
||||||
private dnsRecordHandler!: handlers.DnsRecordHandler;
|
private dnsRecordHandler!: handlers.DnsRecordHandler;
|
||||||
|
private acmeConfigHandler!: handlers.AcmeConfigHandler;
|
||||||
|
|
||||||
constructor(dcRouterRefArg: DcRouter) {
|
constructor(dcRouterRefArg: DcRouter) {
|
||||||
this.dcRouterRef = dcRouterRefArg;
|
this.dcRouterRef = dcRouterRefArg;
|
||||||
@@ -102,6 +103,7 @@ export class OpsServer {
|
|||||||
this.dnsProviderHandler = new handlers.DnsProviderHandler(this);
|
this.dnsProviderHandler = new handlers.DnsProviderHandler(this);
|
||||||
this.domainHandler = new handlers.DomainHandler(this);
|
this.domainHandler = new handlers.DomainHandler(this);
|
||||||
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
|
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
|
||||||
|
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
|
||||||
|
|
||||||
console.log('✅ OpsServer TypedRequest handlers initialized');
|
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 };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,4 +16,5 @@ export * from './network-target.handler.js';
|
|||||||
export * from './users.handler.js';
|
export * from './users.handler.js';
|
||||||
export * from './dns-provider.handler.js';
|
export * from './dns-provider.handler.js';
|
||||||
export * from './domain.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';
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -6,4 +6,5 @@ export * from './target-profile.js';
|
|||||||
export * from './vpn.js';
|
export * from './vpn.js';
|
||||||
export * from './dns-provider.js';
|
export * from './dns-provider.js';
|
||||||
export * from './domain.js';
|
export * from './domain.js';
|
||||||
export * from './dns-record.js';
|
export * from './dns-record.js';
|
||||||
|
export * from './acme-config.js';
|
||||||
@@ -17,7 +17,8 @@ export type TApiTokenScope =
|
|||||||
| 'targets:read' | 'targets:write'
|
| 'targets:read' | 'targets:write'
|
||||||
| 'dns-providers:read' | 'dns-providers:write'
|
| 'dns-providers:read' | 'dns-providers:write'
|
||||||
| 'domains:read' | 'domains: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)
|
// 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,4 +16,5 @@ export * from './network-targets.js';
|
|||||||
export * from './users.js';
|
export * from './users.js';
|
||||||
export * from './dns-providers.js';
|
export * from './dns-providers.js';
|
||||||
export * from './domains.js';
|
export * from './domains.js';
|
||||||
export * from './dns-records.js';
|
export * from './dns-records.js';
|
||||||
|
export * from './acme-config.js';
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.7.1',
|
version: '13.8.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,6 +197,28 @@ export const certificateStatePart = await appState.getStatePart<ICertificateStat
|
|||||||
'soft'
|
'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
|
// 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
|
// Route Management Actions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -23,17 +23,25 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
@state()
|
@state()
|
||||||
accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState()!;
|
accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState()!;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor acmeState: appstate.IAcmeConfigState = appstate.acmeConfigStatePart.getState()!;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
const sub = appstate.certificateStatePart.select().subscribe((newState) => {
|
const certSub = appstate.certificateStatePart.select().subscribe((newState) => {
|
||||||
this.certState = 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() {
|
async connectedCallback() {
|
||||||
await super.connectedCallback();
|
await super.connectedCallback();
|
||||||
await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null);
|
await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null);
|
||||||
|
await appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
@@ -46,6 +54,62 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
gap: 24px;
|
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 {
|
.statusBadge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -162,12 +226,151 @@ export class OpsViewCertificates extends DeesElement {
|
|||||||
<dees-heading level="3">Certificates</dees-heading>
|
<dees-heading level="3">Certificates</dees-heading>
|
||||||
|
|
||||||
<div class="certificatesContainer">
|
<div class="certificatesContainer">
|
||||||
|
${this.renderAcmeSettingsCard()}
|
||||||
${this.renderStatsTiles(summary)}
|
${this.renderStatsTiles(summary)}
|
||||||
${this.renderCertificateTable()}
|
${this.renderCertificateTable()}
|
||||||
</div>
|
</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 {
|
private renderStatsTiles(summary: appstate.ICertificateState['summary']): TemplateResult {
|
||||||
const tiles: IStatsTile[] = [
|
const tiles: IStatsTile[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user