import * as plugins from '../../plugins.js'; import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js'; import type { ICertificateData, IRouteForwardConfig, IDomainOptions } from '../models/certificate-types.js'; import { Port80HandlerEvents, CertProvisionerEvents } from '../events/certificate-events.js'; import { Port80Handler } from '../../http/port80/port80-handler.js'; // Interface for NetworkProxyBridge interface INetworkProxyBridge { applyExternalCertificate(certData: ICertificateData): void; } /** * Type for static certificate provisioning */ export type TCertProvisionObject = plugins.tsclass.network.ICert | 'http01' | 'dns01'; /** * Interface for routes that need certificates */ interface ICertRoute { domain: string; route: IRouteConfig; tlsMode: 'terminate' | 'terminate-and-reencrypt'; } /** * CertProvisioner manages certificate provisioning and renewal workflows, * unifying static certificates and HTTP-01 challenges via Port80Handler. * * This class directly works with route configurations instead of converting to domain configs. */ export class CertProvisioner extends plugins.EventEmitter { private routeConfigs: IRouteConfig[]; private certRoutes: ICertRoute[] = []; private port80Handler: Port80Handler; private networkProxyBridge: INetworkProxyBridge; private certProvisionFunction?: (domain: string) => Promise; private routeForwards: IRouteForwardConfig[]; private renewThresholdDays: number; private renewCheckIntervalHours: number; private autoRenew: boolean; private renewManager?: plugins.taskbuffer.TaskManager; // Track provisioning type per domain private provisionMap: Map; /** * Extract routes that need certificates * @param routes Route configurations */ private extractCertificateRoutesFromRoutes(routes: IRouteConfig[]): ICertRoute[] { const certRoutes: ICertRoute[] = []; // 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]; // For each domain in the route, create a certRoute entry for (const domain of domains) { // Skip wildcard domains that can't use ACME unless we have a certProvider if (domain.includes('*') && (!this.certProvisionFunction || this.certProvisionFunction.length === 0)) { console.warn(`Skipping wildcard domain that requires a certProvisionFunction: ${domain}`); continue; } certRoutes.push({ domain, route, tlsMode: route.action.tls.mode }); } } } return certRoutes; } /** * Constructor for CertProvisioner * * @param routeConfigs Array of route configurations * @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 routeForwards Route-specific forwarding configs for ACME challenges */ constructor( routeConfigs: IRouteConfig[], port80Handler: Port80Handler, networkProxyBridge: INetworkProxyBridge, certProvider?: (domain: string) => Promise, renewThresholdDays: number = 30, renewCheckIntervalHours: number = 24, autoRenew: boolean = true, routeForwards: IRouteForwardConfig[] = [] ) { super(); this.routeConfigs = routeConfigs; this.port80Handler = port80Handler; this.networkProxyBridge = networkProxyBridge; this.certProvisionFunction = certProvider; this.renewThresholdDays = renewThresholdDays; this.renewCheckIntervalHours = renewCheckIntervalHours; this.autoRenew = autoRenew; this.provisionMap = new Map(); this.routeForwards = routeForwards; // Extract certificate routes during instantiation this.certRoutes = this.extractCertificateRoutesFromRoutes(routeConfigs); } /** * Start initial provisioning and schedule renewals. */ public async start(): Promise { // Subscribe to Port80Handler certificate events this.setupEventSubscriptions(); // Apply route forwarding for ACME challenges this.setupForwardingConfigs(); // Initial provisioning for all domains in routes await this.provisionAllCertificates(); // Schedule renewals if enabled if (this.autoRenew) { this.scheduleRenewals(); } } /** * Set up event subscriptions for certificate events */ private setupEventSubscriptions(): void { this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (data: ICertificateData) => { // Add route reference if we have it const routeRef = this.findRouteForDomain(data.domain); const enhancedData: ICertificateData = { ...data, source: 'http01', isRenewal: false, routeReference: routeRef ? { routeId: routeRef.route.name, routeName: routeRef.route.name } : undefined }; this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, enhancedData); }); this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (data: ICertificateData) => { // Add route reference if we have it const routeRef = this.findRouteForDomain(data.domain); const enhancedData: ICertificateData = { ...data, source: 'http01', isRenewal: true, routeReference: routeRef ? { routeId: routeRef.route.name, routeName: routeRef.route.name } : undefined }; this.emit(CertProvisionerEvents.CERTIFICATE_RENEWED, enhancedData); }); this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, (error) => { this.emit(CertProvisionerEvents.CERTIFICATE_FAILED, error); }); } /** * Find a route for a given domain */ private findRouteForDomain(domain: string): ICertRoute | undefined { return this.certRoutes.find(certRoute => certRoute.domain === domain); } /** * Set up forwarding configurations for the Port80Handler */ private setupForwardingConfigs(): void { for (const config of this.routeForwards) { const domainOptions: IDomainOptions = { domainName: config.domain, sslRedirect: config.sslRedirect || false, acmeMaintenance: false, forward: config.target ? { ip: config.target.host, port: config.target.port } : undefined }; this.port80Handler.addDomain(domainOptions); } } /** * Provision certificates for all routes that need them */ private async provisionAllCertificates(): Promise { for (const certRoute of this.certRoutes) { await this.provisionCertificateForRoute(certRoute); } } /** * Provision a certificate for a route */ private async provisionCertificateForRoute(certRoute: ICertRoute): Promise { const { domain, route } = certRoute; 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} on route ${route.name || 'unnamed'}:`, err); } } else if (isWildcard) { // No certProvider: cannot handle wildcard without DNS-01 support console.warn(`Skipping wildcard domain without certProvisionFunction: ${domain}`); return; } // Store the route reference with the provision type this.provisionMap.set(domain, { type: provision === 'http01' || provision === 'dns01' ? provision : 'static', routeRef: certRoute }); // Handle different provisioning methods if (provision === 'http01') { if (isWildcard) { console.warn(`Skipping HTTP-01 for wildcard domain: ${domain}`); return; } this.port80Handler.addDomain({ domainName: domain, sslRedirect: true, acmeMaintenance: true, routeReference: { routeId: route.name || domain, routeName: route.name } }); } else if (provision === 'dns01') { // DNS-01 challenges would be handled by the certProvisionFunction // DNS-01 handling would go here if implemented console.log(`DNS-01 challenge type set for ${domain}`); } else { // Static certificate (e.g., DNS-01 provisioned or user-provided) 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, routeReference: { routeId: route.name || domain, routeName: route.name } }; 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, info] of this.provisionMap.entries()) { // Skip wildcard domains for HTTP-01 challenges if (domain.includes('*') && info.type === 'http01') continue; try { await this.renewCertificateForDomain(domain, info.type, info.routeRef); } 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 * @param certRoute The route reference for this domain */ private async renewCertificateForDomain( domain: string, provisionType: 'http01' | 'dns01' | 'static', certRoute?: ICertRoute ): 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 routeRef = certRoute?.route; const certData: ICertificateData = { domain: certObj.domainName, certificate: certObj.publicKey, privateKey: certObj.privateKey, expiryDate: new Date(certObj.validUntil), source: 'static', isRenewal: true, routeReference: routeRef ? { routeId: routeRef.name || domain, routeName: routeRef.name } : undefined }; 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. * This will look for a matching route configuration and provision accordingly. * * @param domain Domain name to provision */ public async requestCertificate(domain: string): Promise { const isWildcard = domain.includes('*'); // Find matching route const certRoute = this.findRouteForDomain(domain); // 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 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, routeReference: certRoute ? { routeId: certRoute.route.name || domain, routeName: certRoute.route.name } : undefined }; 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; routeId?: string; routeName?: string; }): Promise { const domainOptions: IDomainOptions = { domainName: domain, sslRedirect: options?.sslRedirect ?? true, acmeMaintenance: options?.acmeMaintenance ?? true, routeReference: { routeId: options?.routeId, routeName: options?.routeName } }; this.port80Handler.addDomain(domainOptions); // Find matching route or create a generic one const existingRoute = this.findRouteForDomain(domain); if (existingRoute) { await this.provisionCertificateForRoute(existingRoute); } else { // We don't have a route, just provision the domain const isWildcard = domain.includes('*'); let provision: TCertProvisionObject = 'http01'; if (this.certProvisionFunction) { provision = await this.certProvisionFunction(domain); } else if (isWildcard) { throw new Error(`Cannot request certificate for wildcard domain without certProvisionFunction: ${domain}`); } this.provisionMap.set(domain, { type: provision === 'http01' || provision === 'dns01' ? provision : 'static' }); 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: false, routeReference: { routeId: options?.routeId, routeName: options?.routeName } }; this.networkProxyBridge.applyExternalCertificate(certData); this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, certData); } } } /** * Update routes with new configurations * This replaces all existing routes with new ones and re-provisions certificates as needed * * @param newRoutes New route configurations to use */ public async updateRoutes(newRoutes: IRouteConfig[]): Promise { // Store the new route configs this.routeConfigs = newRoutes; // Extract new certificate routes const newCertRoutes = this.extractCertificateRoutesFromRoutes(newRoutes); // Find domains that no longer need certificates const oldDomains = new Set(this.certRoutes.map(r => r.domain)); const newDomains = new Set(newCertRoutes.map(r => r.domain)); // Domains to remove const domainsToRemove = [...oldDomains].filter(d => !newDomains.has(d)); // Remove obsolete domains from provision map for (const domain of domainsToRemove) { this.provisionMap.delete(domain); } // Update the cert routes this.certRoutes = newCertRoutes; // Provision certificates for new routes for (const certRoute of newCertRoutes) { if (!oldDomains.has(certRoute.domain)) { await this.provisionCertificateForRoute(certRoute); } } } } // Type alias for backward compatibility export type TSmartProxyCertProvisionObject = TCertProvisionObject;