import * as plugins from '../plugins.js'; import type { IDomainConfig, ISmartProxyCertProvisionObject } from './classes.pp.interfaces.js'; import { Port80Handler } from '../port80handler/classes.port80handler.js'; import { Port80HandlerEvents } from '../common/types.js'; import { subscribeToPort80Handler } from '../common/eventUtils.js'; import type { ICertificateData } from '../common/types.js'; import type { NetworkProxyBridge } from './classes.pp.networkproxybridge.js'; /** * CertProvisioner manages certificate provisioning and renewal workflows, * unifying static certificates and HTTP-01 challenges via Port80Handler. */ export class CertProvisioner extends plugins.EventEmitter { private domainConfigs: IDomainConfig[]; private port80Handler: Port80Handler; private networkProxyBridge: NetworkProxyBridge; private certProvisionFunction?: (domain: string) => Promise; private forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }>; private renewThresholdDays: number; private renewCheckIntervalHours: number; private autoRenew: boolean; private renewManager?: plugins.taskbuffer.TaskManager; // Track provisioning type per domain: 'http01' or 'static' private provisionMap: Map; /** * @param domainConfigs Array of domain configuration objects * @param port80Handler HTTP-01 challenge handler instance * @param networkProxyBridge Bridge for applying external certificates * @param certProvider Optional callback returning a static cert or 'http01' * @param renewThresholdDays Days before expiry to trigger renewals * @param renewCheckIntervalHours Interval in hours to check for renewals * @param autoRenew Whether to automatically schedule renewals */ constructor( domainConfigs: IDomainConfig[], port80Handler: Port80Handler, networkProxyBridge: NetworkProxyBridge, certProvider?: (domain: string) => Promise, renewThresholdDays: number = 30, renewCheckIntervalHours: number = 24, autoRenew: boolean = true, forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }> = [] ) { super(); this.domainConfigs = domainConfigs; this.port80Handler = port80Handler; this.networkProxyBridge = networkProxyBridge; this.certProvisionFunction = certProvider; this.renewThresholdDays = renewThresholdDays; this.renewCheckIntervalHours = renewCheckIntervalHours; this.autoRenew = autoRenew; this.provisionMap = new Map(); this.forwardConfigs = forwardConfigs; } /** * Start initial provisioning and schedule renewals. */ public async start(): Promise { // Subscribe to Port80Handler certificate events subscribeToPort80Handler(this.port80Handler, { onCertificateIssued: (data: ICertificateData) => { this.emit('certificate', { ...data, source: 'http01', isRenewal: false }); }, onCertificateRenewed: (data: ICertificateData) => { this.emit('certificate', { ...data, source: 'http01', isRenewal: true }); } }); // Apply external forwarding for ACME challenges (e.g. Synology) for (const f of this.forwardConfigs) { this.port80Handler.addDomain({ domainName: f.domain, sslRedirect: f.sslRedirect, acmeMaintenance: false, forward: f.forwardConfig, acmeForward: f.acmeForwardConfig }); } // Initial provisioning for all domains const domains = this.domainConfigs.flatMap(cfg => cfg.domains); for (const domain of domains) { const isWildcard = domain.includes('*'); let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01'; if (this.certProvisionFunction) { try { provision = await this.certProvisionFunction(domain); } catch (err) { console.error(`certProvider error for ${domain}:`, err); } } else if (isWildcard) { // No certProvider: cannot handle wildcard without DNS-01 support console.warn(`Skipping wildcard domain without certProvisionFunction: ${domain}`); continue; } if (provision === 'http01') { if (isWildcard) { console.warn(`Skipping HTTP-01 for wildcard domain: ${domain}`); continue; } this.provisionMap.set(domain, 'http01'); this.port80Handler.addDomain({ domainName: domain, sslRedirect: true, acmeMaintenance: true }); } else { // Static certificate (e.g., DNS-01 provisioned or user-provided) supports wildcard domains this.provisionMap.set(domain, 'static'); const certObj = provision as plugins.tsclass.network.ICert; const certData: ICertificateData = { domain: certObj.domainName, certificate: certObj.publicKey, privateKey: certObj.privateKey, expiryDate: new Date(certObj.validUntil) }; this.networkProxyBridge.applyExternalCertificate(certData); this.emit('certificate', { ...certData, source: 'static', isRenewal: false }); } } // Schedule renewals if enabled if (this.autoRenew) { this.renewManager = new plugins.taskbuffer.TaskManager(); const renewTask = new plugins.taskbuffer.Task({ name: 'CertificateRenewals', taskFunction: async () => { for (const [domain, type] of this.provisionMap.entries()) { // Skip wildcard domains if (domain.includes('*')) continue; try { if (type === 'http01') { await this.port80Handler.renewCertificate(domain); } else if (type === 'static' && this.certProvisionFunction) { const provision2 = await this.certProvisionFunction(domain); if (provision2 !== 'http01') { const certObj = provision2 as plugins.tsclass.network.ICert; const certData: ICertificateData = { domain: certObj.domainName, certificate: certObj.publicKey, privateKey: certObj.privateKey, expiryDate: new Date(certObj.validUntil) }; this.networkProxyBridge.applyExternalCertificate(certData); this.emit('certificate', { ...certData, source: 'static', isRenewal: true }); } } } catch (err) { console.error(`Renewal error for ${domain}:`, err); } } } }); const hours = this.renewCheckIntervalHours; const cronExpr = `0 0 */${hours} * * *`; this.renewManager.addAndScheduleTask(renewTask, cronExpr); this.renewManager.start(); } } /** * Stop all scheduled renewal tasks. */ public async stop(): Promise { // Stop scheduled renewals if (this.renewManager) { this.renewManager.stop(); } } /** * Request a certificate on-demand for the given domain. * @param domain Domain name to provision */ public async requestCertificate(domain: string): Promise { const isWildcard = domain.includes('*'); // Determine provisioning method let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01'; if (this.certProvisionFunction) { provision = await this.certProvisionFunction(domain); } else if (isWildcard) { // Cannot perform HTTP-01 on wildcard without certProvider throw new Error(`Cannot request certificate for wildcard domain without certProvisionFunction: ${domain}`); } if (provision === 'http01') { if (isWildcard) { throw new Error(`Cannot request HTTP-01 certificate for wildcard domain: ${domain}`); } await this.port80Handler.renewCertificate(domain); } else { // Static certificate (e.g., DNS-01 provisioned) supports wildcards const certObj = provision as plugins.tsclass.network.ICert; const certData: ICertificateData = { domain: certObj.domainName, certificate: certObj.publicKey, privateKey: certObj.privateKey, expiryDate: new Date(certObj.validUntil) }; this.networkProxyBridge.applyExternalCertificate(certData); this.emit('certificate', { ...certData, source: 'static', isRenewal: false }); } } }