import * as plugins from '../../plugins.js'; import type { IDomainConfig } from '../../forwarding/config/domain-config.js'; import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js'; import type { ICertificateData, IDomainForwardConfig, IDomainOptions } from '../models/certificate-types.js'; import { Port80HandlerEvents, CertProvisionerEvents } from '../events/certificate-events.js'; import { Port80Handler } from '../../http/port80/port80-handler.js'; // We need to define this interface until we migrate NetworkProxyBridge interface INetworkProxyBridge { applyExternalCertificate(certData: ICertificateData): void; } // This will be imported after NetworkProxyBridge is migrated // import type { NetworkProxyBridge } from '../../proxies/smart-proxy/network-proxy-bridge.js'; // For backward compatibility export type TSmartProxyCertProvisionObject = plugins.tsclass.network.ICert | 'http01'; /** * Type for static certificate provisioning */ export type TCertProvisionObject = plugins.tsclass.network.ICert | 'http01' | 'dns01'; /** * 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: INetworkProxyBridge; private certProvisionFunction?: (domain: string) => Promise; /** * Extract domains from route configurations for certificate management * @param routes Route configurations */ private extractDomainsFromRoutes(routes: IRouteConfig[]): void { // Process all HTTPS routes that need certificates for (const route of routes) { // Only process routes with TLS termination that need certificates if (route.action.type === 'forward' && route.action.tls && (route.action.tls.mode === 'terminate' || route.action.tls.mode === 'terminate-and-reencrypt') && route.match.domains) { // Extract domains from the route const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; // Skip wildcard domains that can't use ACME const eligibleDomains = domains.filter(d => !d.includes('*')); if (eligibleDomains.length > 0) { // Create a domain config object for certificate provisioning const domainConfig: IDomainConfig = { domains: eligibleDomains, forwarding: { type: route.action.tls.mode === 'terminate' ? 'https-terminate-to-http' : 'https-terminate-to-https', target: route.action.target || { host: 'localhost', port: 80 }, // Add any other required properties from the legacy format security: route.action.security || {} } }; this.domainConfigs.push(domainConfig); } } } }; private forwardConfigs: IDomainForwardConfig[]; private renewThresholdDays: number; private renewCheckIntervalHours: number; private autoRenew: boolean; private renewManager?: plugins.taskbuffer.TaskManager; // Track provisioning type per domain 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 * @param forwardConfigs Domain forwarding configurations for ACME challenges */ constructor( routeConfigs: IRouteConfig[], port80Handler: Port80Handler, networkProxyBridge: INetworkProxyBridge, certProvider?: (domain: string) => Promise, renewThresholdDays: number = 30, renewCheckIntervalHours: number = 24, autoRenew: boolean = true, forwardConfigs: IDomainForwardConfig[] = [] ) { super(); this.domainConfigs = []; this.extractDomainsFromRoutes(routeConfigs); 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 this.setupEventSubscriptions(); // Apply external forwarding for ACME challenges this.setupForwardingConfigs(); // Initial provisioning for all domains await this.provisionAllDomains(); // Schedule renewals if enabled if (this.autoRenew) { this.scheduleRenewals(); } } /** * Set up event subscriptions for certificate events */ private setupEventSubscriptions(): void { // We need to reimplement subscribeToPort80Handler here this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (data: ICertificateData) => { this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, { ...data, source: 'http01', isRenewal: false }); }); this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (data: ICertificateData) => { this.emit(CertProvisionerEvents.CERTIFICATE_RENEWED, { ...data, source: 'http01', isRenewal: true }); }); this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, (error) => { this.emit(CertProvisionerEvents.CERTIFICATE_FAILED, error); }); } /** * Set up forwarding configurations for the Port80Handler */ private setupForwardingConfigs(): void { for (const config of this.forwardConfigs) { const domainOptions: IDomainOptions = { domainName: config.domain, sslRedirect: config.sslRedirect || false, acmeMaintenance: false, forward: config.forwardConfig, acmeForward: config.acmeForwardConfig }; this.port80Handler.addDomain(domainOptions); } } /** * Provision certificates for all configured domains */ private async provisionAllDomains(): Promise { const domains = this.domainConfigs.flatMap(cfg => cfg.domains); for (const domain of domains) { await this.provisionDomain(domain); } } /** * Provision a certificate for a single domain * @param domain Domain to provision */ private async provisionDomain(domain: string): Promise { const isWildcard = domain.includes('*'); let provision: TCertProvisionObject = 'http01'; // Try to get a certificate from the provision function 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}`); return; } // Handle different provisioning methods if (provision === 'http01') { if (isWildcard) { console.warn(`Skipping HTTP-01 for wildcard domain: ${domain}`); return; } this.provisionMap.set(domain, 'http01'); this.port80Handler.addDomain({ domainName: domain, sslRedirect: true, acmeMaintenance: true }); } else if (provision === 'dns01') { // DNS-01 challenges would be handled by the certProvisionFunction this.provisionMap.set(domain, 'dns01'); // DNS-01 handling would go here if implemented } else { // Static certificate (e.g., DNS-01 provisioned or user-provided) 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), source: 'static', isRenewal: false }; this.networkProxyBridge.applyExternalCertificate(certData); this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, certData); } } /** * Schedule certificate renewals using a task manager */ private scheduleRenewals(): void { this.renewManager = new plugins.taskbuffer.TaskManager(); const renewTask = new plugins.taskbuffer.Task({ name: 'CertificateRenewals', taskFunction: async () => await this.performRenewals() }); const hours = this.renewCheckIntervalHours; const cronExpr = `0 0 */${hours} * * *`; this.renewManager.addAndScheduleTask(renewTask, cronExpr); this.renewManager.start(); } /** * Perform renewals for all domains that need it */ private async performRenewals(): Promise { for (const [domain, type] of this.provisionMap.entries()) { // Skip wildcard domains for HTTP-01 challenges if (domain.includes('*') && type === 'http01') continue; try { await this.renewDomain(domain, type); } catch (err) { console.error(`Renewal error for ${domain}:`, err); } } } /** * Renew a certificate for a specific domain * @param domain Domain to renew * @param provisionType Type of provisioning for this domain */ private async renewDomain(domain: string, provisionType: 'http01' | 'dns01' | 'static'): Promise { if (provisionType === 'http01') { await this.port80Handler.renewCertificate(domain); } else if ((provisionType === 'static' || provisionType === 'dns01') && this.certProvisionFunction) { const provision = await this.certProvisionFunction(domain); if (provision !== 'http01' && provision !== 'dns01') { 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), source: 'static', isRenewal: true }; this.networkProxyBridge.applyExternalCertificate(certData); this.emit(CertProvisionerEvents.CERTIFICATE_RENEWED, certData); } } } /** * Stop all scheduled renewal tasks. */ public async stop(): Promise { 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: TCertProvisionObject = '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 if (provision === 'dns01') { // DNS-01 challenges would be handled by external mechanisms // This is a placeholder for future implementation console.log(`DNS-01 challenge requested for ${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), source: 'static', isRenewal: false }; this.networkProxyBridge.applyExternalCertificate(certData); this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, certData); } } /** * Add a new domain for certificate provisioning * @param domain Domain to add * @param options Domain configuration options */ public async addDomain(domain: string, options?: { sslRedirect?: boolean; acmeMaintenance?: boolean; }): Promise { const domainOptions: IDomainOptions = { domainName: domain, sslRedirect: options?.sslRedirect || true, acmeMaintenance: options?.acmeMaintenance || true }; this.port80Handler.addDomain(domainOptions); await this.provisionDomain(domain); } } // For backward compatibility export { CertProvisioner as CertificateProvisioner }