183 lines
6.1 KiB
TypeScript
183 lines
6.1 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|