fix(certificates): simplify approach
This commit is contained in:
517
ts/proxies/smart-proxy/certificate-manager.ts
Normal file
517
ts/proxies/smart-proxy/certificate-manager.ts
Normal file
@ -0,0 +1,517 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { NetworkProxy } from '../network-proxy/index.js';
|
||||
import type { IRouteConfig, IRouteTls } from './models/route-types.js';
|
||||
import { CertStore } from './cert-store.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();
|
||||
|
||||
// Track certificate status by route name
|
||||
private certStatus: Map<string, ICertStatus> = new Map();
|
||||
|
||||
// Callback to update SmartProxy routes for challenges
|
||||
private updateRoutesCallback?: (routes: IRouteConfig[]) => Promise<void>;
|
||||
|
||||
constructor(
|
||||
private routes: IRouteConfig[],
|
||||
private certDir: string = './certs',
|
||||
private acmeOptions?: {
|
||||
email?: string;
|
||||
useProduction?: boolean;
|
||||
port?: number;
|
||||
}
|
||||
) {
|
||||
this.certStore = new CertStore(certDir);
|
||||
}
|
||||
|
||||
public setNetworkProxy(networkProxy: NetworkProxy): void {
|
||||
this.networkProxy = networkProxy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 SmartAcme instance with our challenge handler
|
||||
this.smartAcme = new plugins.smartacme.SmartAcme({
|
||||
accountEmail: this.acmeOptions.email,
|
||||
environment: this.acmeOptions.useProduction ? 'production' : 'integration',
|
||||
certManager: new InMemoryCertManager()
|
||||
});
|
||||
|
||||
// The challenge handler is now embedded in the SmartAcme config above
|
||||
// SmartAcme will handle the challenge internally
|
||||
|
||||
await this.smartAcme.start();
|
||||
}
|
||||
|
||||
// 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'
|
||||
);
|
||||
|
||||
for (const route of certRoutes) {
|
||||
try {
|
||||
await this.provisionCertificate(route);
|
||||
} catch (error) {
|
||||
console.error(`Failed to provision certificate for route ${route.name}: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision certificate for a single route
|
||||
*/
|
||||
public async provisionCertificate(route: IRouteConfig): Promise<void> {
|
||||
const tls = route.action.tls;
|
||||
if (!tls || (tls.mode !== 'terminate' && tls.mode !== 'terminate-and-reencrypt')) {
|
||||
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');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
console.log(`Requesting ACME certificate for ${domains.join(', ')}`);
|
||||
this.updateCertStatus(routeName, 'pending', 'acme');
|
||||
|
||||
try {
|
||||
// Use smartacme to get certificate
|
||||
const cert = await this.smartAcme.getCertificateForDomain(primaryDomain);
|
||||
|
||||
// smartacme returns a Cert object with these properties
|
||||
const certData: ICertificateData = {
|
||||
cert: cert.publicKey,
|
||||
key: cert.privateKey,
|
||||
ca: cert.publicKey, // Use same as cert for now
|
||||
expiryDate: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days
|
||||
issueDate: new Date()
|
||||
};
|
||||
|
||||
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();
|
||||
const expiryThreshold = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||
|
||||
return cert.expiryDate > expiryThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ACME challenge route
|
||||
* NOTE: SmartProxy already handles path-based routing and priority
|
||||
*/
|
||||
private createChallengeRoute(): IRouteConfig {
|
||||
return {
|
||||
name: 'acme-challenge',
|
||||
priority: 1000, // High priority to ensure it's checked first
|
||||
match: {
|
||||
ports: 80,
|
||||
path: '/.well-known/acme-challenge/*'
|
||||
},
|
||||
action: {
|
||||
type: 'static',
|
||||
handler: async (context) => {
|
||||
const token = context.path?.split('/').pop();
|
||||
const keyAuth = token ? this.pendingChallenges.get(token) : undefined;
|
||||
|
||||
if (keyAuth) {
|
||||
return {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: keyAuth
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 404,
|
||||
body: 'Not found'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add challenge route to SmartProxy
|
||||
*/
|
||||
private async addChallengeRoute(): Promise<void> {
|
||||
if (!this.updateRoutesCallback) {
|
||||
throw new Error('No route update callback set');
|
||||
}
|
||||
|
||||
const challengeRoute = this.createChallengeRoute();
|
||||
const updatedRoutes = [...this.routes, challengeRoute];
|
||||
|
||||
await this.updateRoutesCallback(updatedRoutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove challenge route from SmartProxy
|
||||
*/
|
||||
private async removeChallengeRoute(): Promise<void> {
|
||||
if (!this.updateRoutesCallback) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredRoutes = this.routes.filter(r => r.name !== 'acme-challenge');
|
||||
await this.updateRoutesCallback(filteredRoutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle ACME challenge
|
||||
*/
|
||||
private async handleChallenge(token: string, keyAuth: string): Promise<void> {
|
||||
this.pendingChallenges.set(token, keyAuth);
|
||||
|
||||
// Add challenge route if it's the first challenge
|
||||
if (this.pendingChallenges.size === 1) {
|
||||
await this.addChallengeRoute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup ACME challenge
|
||||
*/
|
||||
private async cleanupChallenge(token: string): Promise<void> {
|
||||
this.pendingChallenges.delete(token);
|
||||
|
||||
// Remove challenge route if no more challenges
|
||||
if (this.pendingChallenges.size === 0) {
|
||||
await this.removeChallengeRoute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop certificate manager
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (this.renewalTimer) {
|
||||
clearInterval(this.renewalTimer);
|
||||
this.renewalTimer = null;
|
||||
}
|
||||
|
||||
if (this.smartAcme) {
|
||||
await this.smartAcme.stop();
|
||||
}
|
||||
|
||||
// Remove any active challenge routes
|
||||
if (this.pendingChallenges.size > 0) {
|
||||
this.pendingChallenges.clear();
|
||||
await this.removeChallengeRoute();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ACME options (for recreating after route updates)
|
||||
*/
|
||||
public getAcmeOptions(): { email?: string; useProduction?: boolean; port?: number } | undefined {
|
||||
return this.acmeOptions;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple in-memory certificate manager for SmartAcme
|
||||
* We only use this to satisfy SmartAcme's interface - actual storage is handled by CertStore
|
||||
*/
|
||||
class InMemoryCertManager implements plugins.smartacme.ICertManager {
|
||||
private store = new Map<string, any>();
|
||||
|
||||
// Required methods from ICertManager interface
|
||||
public async init(): Promise<void> {
|
||||
// Initialization if needed
|
||||
}
|
||||
|
||||
public async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
|
||||
return this.store.get(domainName) || null;
|
||||
}
|
||||
|
||||
public async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
|
||||
this.store.set(cert.domainName, cert);
|
||||
}
|
||||
|
||||
public async deleteCertificate(domainName: string): Promise<void> {
|
||||
this.store.delete(domainName);
|
||||
}
|
||||
|
||||
public async getCertificates(): Promise<plugins.smartacme.Cert[]> {
|
||||
return Array.from(this.store.values());
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
// Cleanup if needed
|
||||
}
|
||||
|
||||
public async close(): Promise<void> {
|
||||
// Required by interface
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
public async wipe(): Promise<void> {
|
||||
// Required by interface
|
||||
this.store.clear();
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user