519 lines
17 KiB
TypeScript
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; |