smartproxy/ts/certificate/providers/cert-provisioner.ts
2025-05-10 13:59:34 +00:00

519 lines
17 KiB
TypeScript

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<TCertProvisionObject>;
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<string, { type: 'http01' | 'dns01' | 'static', routeRef?: ICertRoute }>;
/**
* 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<TCertProvisionObject>,
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<void> {
// 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<void> {
for (const certRoute of this.certRoutes) {
await this.provisionCertificateForRoute(certRoute);
}
}
/**
* Provision a certificate for a route
*/
private async provisionCertificateForRoute(certRoute: ICertRoute): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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;