632 lines
20 KiB
TypeScript
632 lines
20 KiB
TypeScript
import * as plugins from '../../plugins.js';
|
|
import { NetworkProxy } from '../network-proxy/index.js';
|
|
import type { IRouteConfig, IRouteTls } from './models/route-types.js';
|
|
import type { IAcmeOptions } from './models/interfaces.js';
|
|
import { CertStore } from './cert-store.js';
|
|
import type { AcmeStateManager } from './acme-state-manager.js';
|
|
|
|
export interface ICertStatus {
|
|
domain: string;
|
|
status: 'valid' | 'pending' | 'expired' | 'error';
|
|
expiryDate?: Date;
|
|
issueDate?: Date;
|
|
source: 'static' | 'acme';
|
|
error?: string;
|
|
}
|
|
|
|
export interface ICertificateData {
|
|
cert: string;
|
|
key: string;
|
|
ca?: string;
|
|
expiryDate: Date;
|
|
issueDate: Date;
|
|
}
|
|
|
|
export class SmartCertManager {
|
|
private certStore: CertStore;
|
|
private smartAcme: plugins.smartacme.SmartAcme | null = null;
|
|
private networkProxy: NetworkProxy | null = null;
|
|
private renewalTimer: NodeJS.Timeout | null = null;
|
|
private pendingChallenges: Map<string, string> = new Map();
|
|
private challengeRoute: IRouteConfig | null = null;
|
|
|
|
// Track certificate status by route name
|
|
private certStatus: Map<string, ICertStatus> = new Map();
|
|
|
|
// Global ACME defaults from top-level configuration
|
|
private globalAcmeDefaults: IAcmeOptions | null = null;
|
|
|
|
// Callback to update SmartProxy routes for challenges
|
|
private updateRoutesCallback?: (routes: IRouteConfig[]) => Promise<void>;
|
|
|
|
// Flag to track if challenge route is currently active
|
|
private challengeRouteActive: boolean = false;
|
|
|
|
// Flag to track if provisioning is in progress
|
|
private isProvisioning: boolean = false;
|
|
|
|
// ACME state manager reference
|
|
private acmeStateManager: AcmeStateManager | null = null;
|
|
|
|
constructor(
|
|
private routes: IRouteConfig[],
|
|
private certDir: string = './certs',
|
|
private acmeOptions?: {
|
|
email?: string;
|
|
useProduction?: boolean;
|
|
port?: number;
|
|
},
|
|
private initialState?: {
|
|
challengeRouteActive?: boolean;
|
|
}
|
|
) {
|
|
this.certStore = new CertStore(certDir);
|
|
|
|
// Apply initial state if provided
|
|
if (initialState) {
|
|
this.challengeRouteActive = initialState.challengeRouteActive || false;
|
|
}
|
|
}
|
|
|
|
public setNetworkProxy(networkProxy: NetworkProxy): void {
|
|
this.networkProxy = networkProxy;
|
|
}
|
|
|
|
/**
|
|
* Get the current state of the certificate manager
|
|
*/
|
|
public getState(): { challengeRouteActive: boolean } {
|
|
return {
|
|
challengeRouteActive: this.challengeRouteActive
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Set the ACME state manager
|
|
*/
|
|
public setAcmeStateManager(stateManager: AcmeStateManager): void {
|
|
this.acmeStateManager = stateManager;
|
|
}
|
|
|
|
/**
|
|
* Set global ACME defaults from top-level configuration
|
|
*/
|
|
public setGlobalAcmeDefaults(defaults: IAcmeOptions): void {
|
|
this.globalAcmeDefaults = defaults;
|
|
}
|
|
|
|
/**
|
|
* Set callback for updating routes (used for challenge routes)
|
|
*/
|
|
public setUpdateRoutesCallback(callback: (routes: IRouteConfig[]) => Promise<void>): void {
|
|
this.updateRoutesCallback = callback;
|
|
}
|
|
|
|
/**
|
|
* Initialize certificate manager and provision certificates for all routes
|
|
*/
|
|
public async initialize(): Promise<void> {
|
|
// Create certificate directory if it doesn't exist
|
|
await this.certStore.initialize();
|
|
|
|
// Initialize SmartAcme if we have any ACME routes
|
|
const hasAcmeRoutes = this.routes.some(r =>
|
|
r.action.tls?.certificate === 'auto'
|
|
);
|
|
|
|
if (hasAcmeRoutes && this.acmeOptions?.email) {
|
|
// Create HTTP-01 challenge handler
|
|
const http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler();
|
|
|
|
// Set up challenge handler integration with our routing
|
|
this.setupChallengeHandler(http01Handler);
|
|
|
|
// Create SmartAcme instance with built-in MemoryCertManager and HTTP-01 handler
|
|
this.smartAcme = new plugins.smartacme.SmartAcme({
|
|
accountEmail: this.acmeOptions.email,
|
|
environment: this.acmeOptions.useProduction ? 'production' : 'integration',
|
|
certManager: new plugins.smartacme.certmanagers.MemoryCertManager(),
|
|
challengeHandlers: [http01Handler]
|
|
});
|
|
|
|
await this.smartAcme.start();
|
|
|
|
// Add challenge route once at initialization if not already active
|
|
if (!this.challengeRouteActive) {
|
|
console.log('Adding ACME challenge route during initialization');
|
|
await this.addChallengeRoute();
|
|
} else {
|
|
console.log('Challenge route already active from previous instance');
|
|
}
|
|
}
|
|
|
|
// Provision certificates for all routes
|
|
await this.provisionAllCertificates();
|
|
|
|
// Start renewal timer
|
|
this.startRenewalTimer();
|
|
}
|
|
|
|
/**
|
|
* Provision certificates for all routes that need them
|
|
*/
|
|
private async provisionAllCertificates(): Promise<void> {
|
|
const certRoutes = this.routes.filter(r =>
|
|
r.action.tls?.mode === 'terminate' ||
|
|
r.action.tls?.mode === 'terminate-and-reencrypt'
|
|
);
|
|
|
|
// Set provisioning flag to prevent concurrent operations
|
|
this.isProvisioning = true;
|
|
|
|
try {
|
|
for (const route of certRoutes) {
|
|
try {
|
|
await this.provisionCertificate(route, true); // Allow concurrent since we're managing it here
|
|
} catch (error) {
|
|
console.error(`Failed to provision certificate for route ${route.name}: ${error}`);
|
|
}
|
|
}
|
|
} finally {
|
|
this.isProvisioning = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Provision certificate for a single route
|
|
*/
|
|
public async provisionCertificate(route: IRouteConfig, allowConcurrent: boolean = false): Promise<void> {
|
|
const tls = route.action.tls;
|
|
if (!tls || (tls.mode !== 'terminate' && tls.mode !== 'terminate-and-reencrypt')) {
|
|
return;
|
|
}
|
|
|
|
// Check if provisioning is already in progress (prevent concurrent provisioning)
|
|
if (!allowConcurrent && this.isProvisioning) {
|
|
console.log(`Certificate provisioning already in progress, skipping ${route.name}`);
|
|
return;
|
|
}
|
|
|
|
const domains = this.extractDomainsFromRoute(route);
|
|
if (domains.length === 0) {
|
|
console.warn(`Route ${route.name} has TLS termination but no domains`);
|
|
return;
|
|
}
|
|
|
|
const primaryDomain = domains[0];
|
|
|
|
if (tls.certificate === 'auto') {
|
|
// ACME certificate
|
|
await this.provisionAcmeCertificate(route, domains);
|
|
} else if (typeof tls.certificate === 'object') {
|
|
// Static certificate
|
|
await this.provisionStaticCertificate(route, primaryDomain, tls.certificate);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Provision ACME certificate
|
|
*/
|
|
private async provisionAcmeCertificate(
|
|
route: IRouteConfig,
|
|
domains: string[]
|
|
): Promise<void> {
|
|
if (!this.smartAcme) {
|
|
throw new Error(
|
|
'SmartAcme not initialized. This usually means no ACME email was provided. ' +
|
|
'Please ensure you have configured ACME with an email address either:\n' +
|
|
'1. In the top-level "acme" configuration\n' +
|
|
'2. In the route\'s "tls.acme" configuration'
|
|
);
|
|
}
|
|
|
|
const primaryDomain = domains[0];
|
|
const routeName = route.name || primaryDomain;
|
|
|
|
// Check if we already have a valid certificate
|
|
const existingCert = await this.certStore.getCertificate(routeName);
|
|
if (existingCert && this.isCertificateValid(existingCert)) {
|
|
console.log(`Using existing valid certificate for ${primaryDomain}`);
|
|
await this.applyCertificate(primaryDomain, existingCert);
|
|
this.updateCertStatus(routeName, 'valid', 'acme', existingCert);
|
|
return;
|
|
}
|
|
|
|
// Apply renewal threshold from global defaults or route config
|
|
const renewThreshold = route.action.tls?.acme?.renewBeforeDays ||
|
|
this.globalAcmeDefaults?.renewThresholdDays ||
|
|
30;
|
|
|
|
console.log(`Requesting ACME certificate for ${domains.join(', ')} (renew ${renewThreshold} days before expiry)`);
|
|
this.updateCertStatus(routeName, 'pending', 'acme');
|
|
|
|
try {
|
|
// Challenge route should already be active from initialization
|
|
// No need to add it for each certificate
|
|
|
|
// Use smartacme to get certificate
|
|
const cert = await this.smartAcme.getCertificateForDomain(primaryDomain);
|
|
|
|
// SmartAcme's Cert object has these properties:
|
|
// - publicKey: The certificate PEM string
|
|
// - privateKey: The private key PEM string
|
|
// - csr: Certificate signing request
|
|
// - validUntil: Timestamp in milliseconds
|
|
// - domainName: The domain name
|
|
const certData: ICertificateData = {
|
|
cert: cert.publicKey,
|
|
key: cert.privateKey,
|
|
ca: cert.publicKey, // Use same as cert for now
|
|
expiryDate: new Date(cert.validUntil),
|
|
issueDate: new Date(cert.created)
|
|
};
|
|
|
|
await this.certStore.saveCertificate(routeName, certData);
|
|
await this.applyCertificate(primaryDomain, certData);
|
|
this.updateCertStatus(routeName, 'valid', 'acme', certData);
|
|
|
|
console.log(`Successfully provisioned ACME certificate for ${primaryDomain}`);
|
|
} catch (error) {
|
|
console.error(`Failed to provision ACME certificate for ${primaryDomain}: ${error}`);
|
|
this.updateCertStatus(routeName, 'error', 'acme', undefined, error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Provision static certificate
|
|
*/
|
|
private async provisionStaticCertificate(
|
|
route: IRouteConfig,
|
|
domain: string,
|
|
certConfig: { key: string; cert: string; keyFile?: string; certFile?: string }
|
|
): Promise<void> {
|
|
const routeName = route.name || domain;
|
|
|
|
try {
|
|
let key: string = certConfig.key;
|
|
let cert: string = certConfig.cert;
|
|
|
|
// Load from files if paths are provided
|
|
if (certConfig.keyFile) {
|
|
const keyFile = await plugins.smartfile.SmartFile.fromFilePath(certConfig.keyFile);
|
|
key = keyFile.contents.toString();
|
|
}
|
|
if (certConfig.certFile) {
|
|
const certFile = await plugins.smartfile.SmartFile.fromFilePath(certConfig.certFile);
|
|
cert = certFile.contents.toString();
|
|
}
|
|
|
|
// Parse certificate to get dates
|
|
// Parse certificate to get dates - for now just use defaults
|
|
// TODO: Implement actual certificate parsing if needed
|
|
const certInfo = { validTo: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), validFrom: new Date() };
|
|
|
|
const certData: ICertificateData = {
|
|
cert,
|
|
key,
|
|
expiryDate: certInfo.validTo,
|
|
issueDate: certInfo.validFrom
|
|
};
|
|
|
|
// Save to store for consistency
|
|
await this.certStore.saveCertificate(routeName, certData);
|
|
await this.applyCertificate(domain, certData);
|
|
this.updateCertStatus(routeName, 'valid', 'static', certData);
|
|
|
|
console.log(`Successfully loaded static certificate for ${domain}`);
|
|
} catch (error) {
|
|
console.error(`Failed to provision static certificate for ${domain}: ${error}`);
|
|
this.updateCertStatus(routeName, 'error', 'static', undefined, error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply certificate to NetworkProxy
|
|
*/
|
|
private async applyCertificate(domain: string, certData: ICertificateData): Promise<void> {
|
|
if (!this.networkProxy) {
|
|
console.warn('NetworkProxy not set, cannot apply certificate');
|
|
return;
|
|
}
|
|
|
|
// Apply certificate to NetworkProxy
|
|
this.networkProxy.updateCertificate(domain, certData.cert, certData.key);
|
|
|
|
// Also apply for wildcard if it's a subdomain
|
|
if (domain.includes('.') && !domain.startsWith('*.')) {
|
|
const parts = domain.split('.');
|
|
if (parts.length >= 2) {
|
|
const wildcardDomain = `*.${parts.slice(-2).join('.')}`;
|
|
this.networkProxy.updateCertificate(wildcardDomain, certData.cert, certData.key);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract domains from route configuration
|
|
*/
|
|
private extractDomainsFromRoute(route: IRouteConfig): string[] {
|
|
if (!route.match.domains) {
|
|
return [];
|
|
}
|
|
|
|
const domains = Array.isArray(route.match.domains)
|
|
? route.match.domains
|
|
: [route.match.domains];
|
|
|
|
// Filter out wildcards and patterns
|
|
return domains.filter(d =>
|
|
!d.includes('*') &&
|
|
!d.includes('{') &&
|
|
d.includes('.')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if certificate is valid
|
|
*/
|
|
private isCertificateValid(cert: ICertificateData): boolean {
|
|
const now = new Date();
|
|
|
|
// Use renewal threshold from global defaults or fallback to 30 days
|
|
const renewThresholdDays = this.globalAcmeDefaults?.renewThresholdDays || 30;
|
|
const expiryThreshold = new Date(now.getTime() + renewThresholdDays * 24 * 60 * 60 * 1000);
|
|
|
|
return cert.expiryDate > expiryThreshold;
|
|
}
|
|
|
|
|
|
/**
|
|
* Add challenge route to SmartProxy
|
|
*/
|
|
private async addChallengeRoute(): Promise<void> {
|
|
// Check with state manager first
|
|
if (this.acmeStateManager && this.acmeStateManager.isChallengeRouteActive()) {
|
|
console.log('Challenge route already active in global state, skipping');
|
|
this.challengeRouteActive = true;
|
|
return;
|
|
}
|
|
|
|
if (this.challengeRouteActive) {
|
|
console.log('Challenge route already active locally, skipping');
|
|
return;
|
|
}
|
|
|
|
if (!this.updateRoutesCallback) {
|
|
throw new Error('No route update callback set');
|
|
}
|
|
|
|
if (!this.challengeRoute) {
|
|
throw new Error('Challenge route not initialized');
|
|
}
|
|
const challengeRoute = this.challengeRoute;
|
|
|
|
try {
|
|
const updatedRoutes = [...this.routes, challengeRoute];
|
|
await this.updateRoutesCallback(updatedRoutes);
|
|
this.challengeRouteActive = true;
|
|
|
|
// Register with state manager
|
|
if (this.acmeStateManager) {
|
|
this.acmeStateManager.addChallengeRoute(challengeRoute);
|
|
}
|
|
|
|
console.log('ACME challenge route successfully added');
|
|
} catch (error) {
|
|
console.error('Failed to add challenge route:', error);
|
|
if ((error as any).code === 'EADDRINUSE') {
|
|
throw new Error(`Port ${this.globalAcmeDefaults?.port || 80} is already in use for ACME challenges`);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove challenge route from SmartProxy
|
|
*/
|
|
private async removeChallengeRoute(): Promise<void> {
|
|
if (!this.challengeRouteActive) {
|
|
console.log('Challenge route not active, skipping removal');
|
|
return;
|
|
}
|
|
|
|
if (!this.updateRoutesCallback) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const filteredRoutes = this.routes.filter(r => r.name !== 'acme-challenge');
|
|
await this.updateRoutesCallback(filteredRoutes);
|
|
this.challengeRouteActive = false;
|
|
|
|
// Remove from state manager
|
|
if (this.acmeStateManager) {
|
|
this.acmeStateManager.removeChallengeRoute('acme-challenge');
|
|
}
|
|
|
|
console.log('ACME challenge route successfully removed');
|
|
} catch (error) {
|
|
console.error('Failed to remove challenge route:', error);
|
|
// Reset the flag even on error to avoid getting stuck
|
|
this.challengeRouteActive = false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start renewal timer
|
|
*/
|
|
private startRenewalTimer(): void {
|
|
// Check for renewals every 12 hours
|
|
this.renewalTimer = setInterval(() => {
|
|
this.checkAndRenewCertificates();
|
|
}, 12 * 60 * 60 * 1000);
|
|
|
|
// Also do an immediate check
|
|
this.checkAndRenewCertificates();
|
|
}
|
|
|
|
/**
|
|
* Check and renew certificates that are expiring
|
|
*/
|
|
private async checkAndRenewCertificates(): Promise<void> {
|
|
for (const route of this.routes) {
|
|
if (route.action.tls?.certificate === 'auto') {
|
|
const routeName = route.name || this.extractDomainsFromRoute(route)[0];
|
|
const cert = await this.certStore.getCertificate(routeName);
|
|
|
|
if (cert && !this.isCertificateValid(cert)) {
|
|
console.log(`Certificate for ${routeName} needs renewal`);
|
|
try {
|
|
await this.provisionCertificate(route);
|
|
} catch (error) {
|
|
console.error(`Failed to renew certificate for ${routeName}: ${error}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update certificate status
|
|
*/
|
|
private updateCertStatus(
|
|
routeName: string,
|
|
status: ICertStatus['status'],
|
|
source: ICertStatus['source'],
|
|
certData?: ICertificateData,
|
|
error?: string
|
|
): void {
|
|
this.certStatus.set(routeName, {
|
|
domain: routeName,
|
|
status,
|
|
source,
|
|
expiryDate: certData?.expiryDate,
|
|
issueDate: certData?.issueDate,
|
|
error
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get certificate status for a route
|
|
*/
|
|
public getCertificateStatus(routeName: string): ICertStatus | undefined {
|
|
return this.certStatus.get(routeName);
|
|
}
|
|
|
|
/**
|
|
* Force renewal of a certificate
|
|
*/
|
|
public async renewCertificate(routeName: string): Promise<void> {
|
|
const route = this.routes.find(r => r.name === routeName);
|
|
if (!route) {
|
|
throw new Error(`Route ${routeName} not found`);
|
|
}
|
|
|
|
// Remove existing certificate to force renewal
|
|
await this.certStore.deleteCertificate(routeName);
|
|
await this.provisionCertificate(route);
|
|
}
|
|
|
|
/**
|
|
* Setup challenge handler integration with SmartProxy routing
|
|
*/
|
|
private setupChallengeHandler(http01Handler: plugins.smartacme.handlers.Http01MemoryHandler): void {
|
|
// Use challenge port from global config or default to 80
|
|
const challengePort = this.globalAcmeDefaults?.port || 80;
|
|
|
|
// Create a challenge route that delegates to SmartAcme's HTTP-01 handler
|
|
const challengeRoute: IRouteConfig = {
|
|
name: 'acme-challenge',
|
|
priority: 1000, // High priority
|
|
match: {
|
|
ports: challengePort,
|
|
path: '/.well-known/acme-challenge/*'
|
|
},
|
|
action: {
|
|
type: 'static',
|
|
handler: async (context) => {
|
|
// Extract the token from the path
|
|
const token = context.path?.split('/').pop();
|
|
if (!token) {
|
|
return { status: 404, body: 'Not found' };
|
|
}
|
|
|
|
// Create mock request/response objects for SmartAcme
|
|
const mockReq = {
|
|
url: context.path,
|
|
method: 'GET',
|
|
headers: context.headers || {}
|
|
};
|
|
|
|
let responseData: any = null;
|
|
const mockRes = {
|
|
statusCode: 200,
|
|
setHeader: (name: string, value: string) => {},
|
|
end: (data: any) => {
|
|
responseData = data;
|
|
}
|
|
};
|
|
|
|
// Use SmartAcme's handler
|
|
const handled = await new Promise<boolean>((resolve) => {
|
|
http01Handler.handleRequest(mockReq as any, mockRes as any, () => {
|
|
resolve(false);
|
|
});
|
|
// Give it a moment to process
|
|
setTimeout(() => resolve(true), 100);
|
|
});
|
|
|
|
if (handled && responseData) {
|
|
return {
|
|
status: mockRes.statusCode,
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
body: responseData
|
|
};
|
|
} else {
|
|
return { status: 404, body: 'Not found' };
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Store the challenge route to add it when needed
|
|
this.challengeRoute = challengeRoute;
|
|
}
|
|
|
|
/**
|
|
* Stop certificate manager
|
|
*/
|
|
public async stop(): Promise<void> {
|
|
if (this.renewalTimer) {
|
|
clearInterval(this.renewalTimer);
|
|
this.renewalTimer = null;
|
|
}
|
|
|
|
// Always remove challenge route on shutdown
|
|
if (this.challengeRoute) {
|
|
console.log('Removing ACME challenge route during shutdown');
|
|
await this.removeChallengeRoute();
|
|
}
|
|
|
|
if (this.smartAcme) {
|
|
await this.smartAcme.stop();
|
|
}
|
|
|
|
// Clear any pending challenges
|
|
if (this.pendingChallenges.size > 0) {
|
|
this.pendingChallenges.clear();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get ACME options (for recreating after route updates)
|
|
*/
|
|
public getAcmeOptions(): { email?: string; useProduction?: boolean; port?: number } | undefined {
|
|
return this.acmeOptions;
|
|
}
|
|
}
|
|
|