feat(acme): add DB-backed ACME configuration management and OpsServer certificate settings UI
This commit is contained in:
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user