# ACME/Certificate Simplification Plan for SmartProxy ## Current Status: Implementation in Progress ### Completed Tasks: - ✅ SmartCertManager class created - ✅ CertStore class for file-based certificate storage - ✅ Route types updated with new TLS/ACME interfaces - ✅ Static route handler added to route-connection-handler.ts - ✅ SmartProxy class updated to use SmartCertManager - ✅ NetworkProxyBridge simplified by removing certificate logic - ✅ HTTP index.ts updated to remove port80 exports - ✅ Basic tests created for new certificate functionality - ✅ SmartAcme integration completed using built-in MemoryCertManager ### Remaining Tasks: - ❌ Remove old certificate module and port80 directory - ❌ Update documentation with new configuration format ## Command to reread CLAUDE.md `reread /home/philkunz/.claude/CLAUDE.md` ## Overview Simplify the ACME/Certificate system by consolidating components, removing unnecessary abstraction layers, and integrating directly into SmartProxy's route-based architecture. ## Core Principles 1. **No backward compatibility** - Clean break from legacy implementations 2. **No migration helpers** - Users must update to new configuration format 3. **Remove all legacy code** - Delete deprecated methods and interfaces 4. **Forward-only approach** - Focus on simplicity over compatibility 5. **No complexity for edge cases** - Only support the clean, new way ## Key Discoveries from Implementation Analysis 1. **SmartProxy already supports static routes** - The 'static' type exists in TRouteActionType 2. **Path-based routing works perfectly** - The route matching system handles paths with glob patterns 3. **Dynamic route updates are safe** - SmartProxy's updateRoutes() method handles changes gracefully 4. **Priority-based routing exists** - Routes are sorted by priority, ensuring ACME routes match first 5. **No separate HTTP server needed** - ACME challenges can be regular SmartProxy routes ## Current State Analysis ### Files to be Removed/Replaced ``` ts/certificate/ (ENTIRE DIRECTORY TO BE REMOVED) ├── acme/ │ ├── acme-factory.ts (28 lines) │ ├── challenge-handler.ts (227 lines) │ └── index.ts (2 lines) ├── events/ │ └── certificate-events.ts (75 lines) ├── models/ │ └── certificate-types.ts (168 lines) ├── providers/ │ ├── cert-provisioner.ts (547 lines) │ └── index.ts (2 lines) ├── storage/ │ ├── file-storage.ts (134 lines) │ └── index.ts (2 lines) ├── utils/ │ └── certificate-helpers.ts (166 lines) └── index.ts (75 lines) ts/http/port80/ (ENTIRE SUBDIRECTORY TO BE REMOVED) ├── acme-interfaces.ts ├── challenge-responder.ts ├── port80-handler.ts └── index.ts ts/http/ (KEEP OTHER SUBDIRECTORIES) ├── index.ts (UPDATE to remove port80 exports) ├── models/ (KEEP) ├── redirects/ (KEEP) ├── router/ (KEEP) └── utils/ (KEEP) ts/proxies/smart-proxy/ └── network-proxy-bridge.ts (267 lines - to be simplified) ``` ### Current Dependencies - @push.rocks/smartacme (ACME client) - @push.rocks/smartfile (file operations) - @push.rocks/smartcrypto (certificate operations) - @push.rocks/smartexpress (HTTP server for challenges) ## Detailed Implementation Plan ### Phase 1: Create SmartCertManager #### 1.1 Create certificate-manager.ts ✅ COMPLETED ```typescript // ts/proxies/smart-proxy/certificate-manager.ts 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.Timer | null = null; private pendingChallenges: Map = new Map(); // Track certificate status by route name private certStatus: Map = new Map(); // Callback to update SmartProxy routes for challenges private updateRoutesCallback?: (routes: IRouteConfig[]) => Promise; 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 { this.updateRoutesCallback = callback; } /** * Initialize certificate manager and provision certificates for all routes */ public async initialize(): Promise { // 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' : 'staging', certManager: new InMemoryCertManager(), // Simple in-memory cert manager challengeHandlers: [{ type: 'http-01', setChallenge: async (domain: string, token: string, keyAuth: string) => { await this.handleChallenge(token, keyAuth); }, removeChallenge: async (domain: string, token: string) => { await this.cleanupChallenge(token); } }] }); 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 { 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 { 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 { 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, { altNames: domains.slice(1) }); // smartacme returns a Cert object with these properties const certData: ICertificateData = { cert: cert.cert, key: cert.privateKey, ca: cert.fullChain || cert.cert, // Use fullChain if available expiryDate: new Date(cert.validTo), issueDate: new Date(cert.validFrom) }; 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 { 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) { key = await plugins.smartfile.fs.readFileAsString(certConfig.keyFile); } if (certConfig.certFile) { cert = await plugins.smartfile.fs.readFileAsString(certConfig.certFile); } // Parse certificate to get dates const certInfo = await plugins.smartcrypto.cert.parseCert(cert); 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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.CertManager { private store = new Map(); public async getCert(domain: string): Promise { // SmartAcme uses this to check for existing certs // We return null to force it to always request new certs return null; } public async setCert(domain: string, certificate: any): Promise { // SmartAcme calls this after getting a cert // We ignore it since we handle storage ourselves } public async removeCert(domain: string): Promise { // Not needed for our use case } } ``` #### 1.2 Create cert-store.ts ✅ COMPLETED ```typescript // ts/proxies/smart-proxy/cert-store.ts import * as plugins from '../../plugins.js'; import type { ICertificateData } from './certificate-manager.js'; export class CertStore { constructor(private certDir: string) {} public async initialize(): Promise { await plugins.smartfile.fs.ensureDirectory(this.certDir); } public async getCertificate(routeName: string): Promise { const certPath = this.getCertPath(routeName); const metaPath = `${certPath}/meta.json`; if (!await plugins.smartfile.fs.fileExists(metaPath)) { return null; } try { const meta = await plugins.smartfile.fs.readJson(metaPath); const cert = await plugins.smartfile.fs.readFileAsString(`${certPath}/cert.pem`); const key = await plugins.smartfile.fs.readFileAsString(`${certPath}/key.pem`); let ca: string | undefined; const caPath = `${certPath}/ca.pem`; if (await plugins.smartfile.fs.fileExists(caPath)) { ca = await plugins.smartfile.fs.readFileAsString(caPath); } return { cert, key, ca, expiryDate: new Date(meta.expiryDate), issueDate: new Date(meta.issueDate) }; } catch (error) { console.error(`Failed to load certificate for ${routeName}: ${error}`); return null; } } public async saveCertificate( routeName: string, certData: ICertificateData ): Promise { const certPath = this.getCertPath(routeName); await plugins.smartfile.fs.ensureDirectory(certPath); // Save certificate files await plugins.smartfile.fs.writeFileAsString( `${certPath}/cert.pem`, certData.cert ); await plugins.smartfile.fs.writeFileAsString( `${certPath}/key.pem`, certData.key ); if (certData.ca) { await plugins.smartfile.fs.writeFileAsString( `${certPath}/ca.pem`, certData.ca ); } // Save metadata const meta = { expiryDate: certData.expiryDate.toISOString(), issueDate: certData.issueDate.toISOString(), savedAt: new Date().toISOString() }; await plugins.smartfile.fs.writeJson(`${certPath}/meta.json`, meta); } public async deleteCertificate(routeName: string): Promise { const certPath = this.getCertPath(routeName); if (await plugins.smartfile.fs.fileExists(certPath)) { await plugins.smartfile.fs.removeDirectory(certPath); } } private getCertPath(routeName: string): string { // Sanitize route name for filesystem const safeName = routeName.replace(/[^a-zA-Z0-9-_]/g, '_'); return `${this.certDir}/${safeName}`; } } ``` ### Phase 2: Update Route Types and Handler #### 2.1 Update route-types.ts ✅ COMPLETED ```typescript // Add to ts/proxies/smart-proxy/models/route-types.ts /** * ACME configuration for automatic certificate provisioning */ export interface IRouteAcme { email: string; // Contact email for ACME account useProduction?: boolean; // Use production ACME servers (default: false) challengePort?: number; // Port for HTTP-01 challenges (default: 80) renewBeforeDays?: number; // Days before expiry to renew (default: 30) } /** * Static route handler response */ export interface IStaticResponse { status: number; headers?: Record; body: string | Buffer; } /** * Update IRouteAction to support static handlers * NOTE: The 'static' type already exists in TRouteActionType */ export interface IRouteAction { type: TRouteActionType; target?: IRouteTarget; security?: IRouteSecurity; options?: IRouteOptions; tls?: IRouteTls; redirect?: IRouteRedirect; handler?: (context: IRouteContext) => Promise; // For static routes } /** * Extend IRouteConfig to ensure challenge routes have higher priority */ export interface IRouteConfig { name?: string; match: IRouteMatch; action: IRouteAction; priority?: number; // Already exists - ACME routes should use high priority } /** * Extended TLS configuration for route actions */ export interface IRouteTls { mode: TTlsMode; certificate?: 'auto' | { // Auto = use ACME key: string; // PEM-encoded private key cert: string; // PEM-encoded certificate ca?: string; // PEM-encoded CA chain keyFile?: string; // Path to key file (overrides key) certFile?: string; // Path to cert file (overrides cert) }; acme?: IRouteAcme; // ACME options when certificate is 'auto' versions?: string[]; // Allowed TLS versions (e.g., ['TLSv1.2', 'TLSv1.3']) ciphers?: string; // OpenSSL cipher string honorCipherOrder?: boolean; // Use server's cipher preferences sessionTimeout?: number; // TLS session timeout in seconds } ``` #### 2.2 Add Static Route Handler ✅ COMPLETED ```typescript // Add to ts/proxies/smart-proxy/route-connection-handler.ts /** * Handle the route based on its action type */ switch (route.action.type) { case 'forward': return this.handleForwardAction(socket, record, route, initialChunk); case 'redirect': return this.handleRedirectAction(socket, record, route); case 'block': return this.handleBlockAction(socket, record, route); case 'static': return this.handleStaticAction(socket, record, route); default: console.log(`[${connectionId}] Unknown action type: ${(route.action as any).type}`); socket.end(); this.connectionManager.cleanupConnection(record, 'unknown_action'); } /** * Handle a static action for a route */ private async handleStaticAction( socket: plugins.net.Socket, record: IConnectionRecord, route: IRouteConfig ): Promise { const connectionId = record.id; if (!route.action.handler) { console.error(`[${connectionId}] Static route '${route.name}' has no handler`); socket.end(); this.connectionManager.cleanupConnection(record, 'no_handler'); return; } try { // Build route context const context: IRouteContext = { port: record.localPort, domain: record.lockedDomain, clientIp: record.remoteIP, serverIp: socket.localAddress!, path: record.path, // Will need to be extracted from HTTP request isTls: record.isTLS, tlsVersion: record.tlsVersion, routeName: route.name, routeId: route.name, timestamp: Date.now(), connectionId }; // Call the handler const response = await route.action.handler(context); // Send HTTP response const headers = response.headers || {}; headers['Content-Length'] = Buffer.byteLength(response.body).toString(); let httpResponse = `HTTP/1.1 ${response.status} ${getStatusText(response.status)}\r\n`; for (const [key, value] of Object.entries(headers)) { httpResponse += `${key}: ${value}\r\n`; } httpResponse += '\r\n'; socket.write(httpResponse); socket.write(response.body); socket.end(); this.connectionManager.cleanupConnection(record, 'completed'); } catch (error) { console.error(`[${connectionId}] Error in static handler: ${error}`); socket.end(); this.connectionManager.cleanupConnection(record, 'handler_error'); } } // Helper function for status text function getStatusText(status: number): string { const statusTexts: Record = { 200: 'OK', 404: 'Not Found', 500: 'Internal Server Error' }; return statusTexts[status] || 'Unknown'; } ``` ### Phase 3: SmartProxy Integration #### 3.1 Update SmartProxy class ✅ COMPLETED ```typescript // Changes to ts/proxies/smart-proxy/smart-proxy.ts import { SmartCertManager } from './certificate-manager.js'; // Remove ALL certificate/ACME related imports: // - CertProvisioner // - Port80Handler // - buildPort80Handler // - createPort80HandlerOptions export class SmartProxy extends plugins.EventEmitter { // Replace certProvisioner and port80Handler with just: private certManager: SmartCertManager | null = null; constructor(settingsArg: ISmartProxyOptions) { super(); // ... existing initialization ... // No need for ACME settings in ISmartProxyOptions anymore // Certificate configuration is now in route definitions } /** * Initialize certificate manager */ private async initializeCertificateManager(): Promise { // Extract global ACME options if any routes use auto certificates const autoRoutes = this.settings.routes.filter(r => r.action.tls?.certificate === 'auto' ); if (autoRoutes.length === 0 && !this.hasStaticCertRoutes()) { console.log('No routes require certificate management'); return; } // Use the first auto route's ACME config as defaults const defaultAcme = autoRoutes[0]?.action.tls?.acme; this.certManager = new SmartCertManager( this.settings.routes, './certs', // Certificate directory defaultAcme ? { email: defaultAcme.email, useProduction: defaultAcme.useProduction, port: defaultAcme.challengePort || 80 } : undefined ); // Connect with NetworkProxy if (this.networkProxyBridge.getNetworkProxy()) { this.certManager.setNetworkProxy(this.networkProxyBridge.getNetworkProxy()); } // Set route update callback for ACME challenges this.certManager.setUpdateRoutesCallback(async (routes) => { await this.updateRoutes(routes); }); await this.certManager.initialize(); } /** * Check if we have routes with static certificates */ private hasStaticCertRoutes(): boolean { return this.settings.routes.some(r => r.action.tls?.certificate && r.action.tls.certificate !== 'auto' ); } public async start() { if (this.isShuttingDown) { console.log("Cannot start SmartProxy while it's shutting down"); return; } // Initialize certificate manager before starting servers await this.initializeCertificateManager(); // Initialize and start NetworkProxy if needed if (this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0) { await this.networkProxyBridge.initialize(); // Connect NetworkProxy with certificate manager if (this.certManager) { this.certManager.setNetworkProxy(this.networkProxyBridge.getNetworkProxy()); } await this.networkProxyBridge.start(); } // ... rest of start method ... } public async stop() { console.log('SmartProxy shutting down...'); this.isShuttingDown = true; this.portManager.setShuttingDown(true); // Stop certificate manager if (this.certManager) { await this.certManager.stop(); console.log('Certificate manager stopped'); } // ... rest of stop method ... } /** * Update routes with new configuration */ public async updateRoutes(newRoutes: IRouteConfig[]): Promise { console.log(`Updating routes (${newRoutes.length} routes)`); // Update certificate manager with new routes if (this.certManager) { await this.certManager.stop(); this.certManager = new SmartCertManager( newRoutes, './certs', this.certManager.getAcmeOptions() ); if (this.networkProxyBridge.getNetworkProxy()) { this.certManager.setNetworkProxy(this.networkProxyBridge.getNetworkProxy()); } await this.certManager.initialize(); } // ... rest of updateRoutes method ... } /** * Manually provision a certificate for a route */ public async provisionCertificate(routeName: string): Promise { if (!this.certManager) { throw new Error('Certificate manager not initialized'); } const route = this.settings.routes.find(r => r.name === routeName); if (!route) { throw new Error(`Route ${routeName} not found`); } await this.certManager.provisionCertificate(route); } /** * Force renewal of a certificate */ public async renewCertificate(routeName: string): Promise { if (!this.certManager) { throw new Error('Certificate manager not initialized'); } await this.certManager.renewCertificate(routeName); } /** * Get certificate status for a route */ public getCertificateStatus(routeName: string): ICertStatus | undefined { if (!this.certManager) { return undefined; } return this.certManager.getCertificateStatus(routeName); } } ``` #### 3.2 Simplify NetworkProxyBridge ✅ COMPLETED ```typescript // Simplified ts/proxies/smart-proxy/network-proxy-bridge.ts import * as plugins from '../../plugins.js'; import { NetworkProxy } from '../network-proxy/index.js'; import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces.js'; import type { IRouteConfig } from './models/route-types.js'; export class NetworkProxyBridge { private networkProxy: NetworkProxy | null = null; constructor(private settings: ISmartProxyOptions) {} /** * Get the NetworkProxy instance */ public getNetworkProxy(): NetworkProxy | null { return this.networkProxy; } /** * Initialize NetworkProxy instance */ public async initialize(): Promise { if (!this.networkProxy && this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0) { const networkProxyOptions: any = { port: this.settings.networkProxyPort!, portProxyIntegration: true, logLevel: this.settings.enableDetailedLogging ? 'debug' : 'info' }; this.networkProxy = new NetworkProxy(networkProxyOptions); console.log(`Initialized NetworkProxy on port ${this.settings.networkProxyPort}`); // Apply route configurations to NetworkProxy await this.syncRoutesToNetworkProxy(this.settings.routes || []); } } /** * Sync routes to NetworkProxy */ private async syncRoutesToNetworkProxy(routes: IRouteConfig[]): Promise { if (!this.networkProxy) return; // Convert routes to NetworkProxy format const networkProxyConfigs = routes .filter(route => this.settings.useNetworkProxy?.includes(route.match.domains?.[0]) || this.settings.useNetworkProxy?.includes('*') ) .map(route => this.routeToNetworkProxyConfig(route)); // Apply configurations to NetworkProxy await this.networkProxy.updateProxyConfigs(networkProxyConfigs); } /** * Convert route to NetworkProxy configuration */ private routeToNetworkProxyConfig(route: IRouteConfig): any { // Convert route to NetworkProxy domain config format return { domain: route.match.domains?.[0] || '*', target: route.action.target, tls: route.action.tls, security: route.action.security }; } /** * Check if connection should use NetworkProxy */ public shouldUseNetworkProxy(connection: IConnectionRecord, routeMatch: any): boolean { // Only use NetworkProxy for TLS termination return ( routeMatch.route.action.tls?.mode === 'terminate' || routeMatch.route.action.tls?.mode === 'terminate-and-reencrypt' ) && this.networkProxy !== null; } /** * Pipe connection to NetworkProxy */ public async pipeToNetworkProxy(socket: plugins.net.Socket): Promise { if (!this.networkProxy) { throw new Error('NetworkProxy not initialized'); } const proxySocket = new plugins.net.Socket(); await new Promise((resolve, reject) => { proxySocket.connect(this.settings.networkProxyPort!, 'localhost', () => { console.log(`Connected to NetworkProxy for termination`); resolve(); }); proxySocket.on('error', reject); }); // Pipe the sockets together socket.pipe(proxySocket); proxySocket.pipe(socket); // Handle cleanup const cleanup = () => { socket.unpipe(proxySocket); proxySocket.unpipe(socket); proxySocket.destroy(); }; socket.on('end', cleanup); socket.on('error', cleanup); proxySocket.on('end', cleanup); proxySocket.on('error', cleanup); } /** * Start NetworkProxy */ public async start(): Promise { if (this.networkProxy) { await this.networkProxy.start(); } } /** * Stop NetworkProxy */ public async stop(): Promise { if (this.networkProxy) { await this.networkProxy.stop(); this.networkProxy = null; } } } ``` ### Phase 4: Configuration Examples (No Migration) #### 4.1 New Configuration Format ONLY ```typescript // Update test files to use new structure // test/test.certificate-provisioning.ts import { SmartProxy } from '../ts/proxies/smart-proxy/index.js'; import { expect, tap } from '@push.rocks/tapbundle'; const testProxy = new SmartProxy({ routes: [{ name: 'test-route', match: { ports: 443, domains: 'test.example.com' }, action: { type: 'forward', target: { host: 'localhost', port: 8080 }, tls: { mode: 'terminate', certificate: 'auto', acme: { email: 'test@example.com', useProduction: false } } } }] }); tap.test('should provision certificate automatically', async () => { await testProxy.start(); // Wait for certificate provisioning await new Promise(resolve => setTimeout(resolve, 5000)); const status = testProxy.getCertificateStatus('test-route'); expect(status).toBeDefined(); expect(status.status).toEqual('valid'); expect(status.source).toEqual('acme'); await testProxy.stop(); }); tap.test('should handle static certificates', async () => { const proxy = new SmartProxy({ routes: [{ name: 'static-route', match: { ports: 443, domains: 'static.example.com' }, action: { type: 'forward', target: { host: 'localhost', port: 8080 }, tls: { mode: 'terminate', certificate: { certFile: './test/fixtures/cert.pem', keyFile: './test/fixtures/key.pem' } } } }] }); await proxy.start(); const status = proxy.getCertificateStatus('static-route'); expect(status).toBeDefined(); expect(status.status).toEqual('valid'); expect(status.source).toEqual('static'); await proxy.stop(); }); ``` ### Phase 5: Documentation Update #### 5.1 Update README.md sections ```markdown ## Certificate Management SmartProxy includes built-in certificate management with automatic ACME (Let's Encrypt) support. ### Automatic Certificates (ACME) ```typescript const proxy = new SmartProxy({ routes: [{ name: 'secure-site', match: { ports: 443, domains: ['example.com', 'www.example.com'] }, action: { type: 'forward', target: { host: 'backend', port: 8080 }, tls: { mode: 'terminate', certificate: 'auto', acme: { email: 'admin@example.com', useProduction: true, renewBeforeDays: 30 } } } }] }); ``` ### Static Certificates ```typescript const proxy = new SmartProxy({ routes: [{ name: 'static-cert', match: { ports: 443, domains: 'secure.example.com' }, action: { type: 'forward', target: { host: 'backend', port: 8080 }, tls: { mode: 'terminate', certificate: { certFile: './certs/secure.pem', keyFile: './certs/secure.key' } } } }] }); ``` ### Certificate Management API ```typescript // Get certificate status const status = proxy.getCertificateStatus('route-name'); console.log(status); // { // domain: 'example.com', // status: 'valid', // source: 'acme', // expiryDate: Date, // issueDate: Date // } // Manually provision certificate await proxy.provisionCertificate('route-name'); // Force certificate renewal await proxy.renewCertificate('route-name'); ``` ### Certificate Storage Certificates are stored in the `./certs` directory by default: ``` ./certs/ ├── route-name/ │ ├── cert.pem │ ├── key.pem │ ├── ca.pem (if available) │ └── meta.json ``` ``` ### Phase 5: Update HTTP Module #### 5.1 Update http/index.ts ✅ COMPLETED ```typescript // ts/http/index.ts /** * HTTP functionality module */ // Export types and models export * from './models/http-types.js'; // Export submodules (remove port80 export) export * from './router/index.js'; export * from './redirects/index.js'; // REMOVED: export * from './port80/index.js'; // Convenience namespace exports (no more Port80) export const Http = { // Only router and redirect functionality remain }; ``` ### Phase 6: Cleanup Tasks #### 6.1 File Deletion Script ```bash #!/bin/bash # cleanup-certificates.sh # Remove old certificate module rm -rf ts/certificate/ # Remove entire port80 subdirectory rm -rf ts/http/port80/ # Remove old imports from index files sed -i '/certificate\//d' ts/index.ts sed -i '/port80\//d' ts/http/index.ts # Update plugins.ts to remove unused dependencies (if not used elsewhere) # sed -i '/smartexpress/d' ts/plugins.ts ``` #### 6.2 Key Simplifications Achieved 1. **No custom ACME wrapper** - Direct use of @push.rocks/smartacme 2. **No separate HTTP server** - ACME challenges are regular routes 3. **Built-in path routing** - SmartProxy already handles path-based matching 4. **Built-in priorities** - Routes are already sorted by priority 5. **Safe updates** - Route updates are already thread-safe 6. **Minimal new code** - Mostly configuration and integration The simplification leverages SmartProxy's existing capabilities rather than reinventing them. #### 6.2 Update Package.json ```json { "dependencies": { // Remove if no longer needed elsewhere: // "@push.rocks/smartexpress": "x.x.x" } } ``` ## Implementation Sequence 1. **Day 1: Core Implementation** ✅ COMPLETED - Create SmartCertManager class - Create CertStore - Update route types - Integrated with SmartAcme's built-in handlers 2. **Day 2: Integration** ✅ COMPLETED - Update SmartProxy to use SmartCertManager - Simplify NetworkProxyBridge - Update HTTP index.ts 3. **Day 3: Testing** ✅ COMPLETED - Created test.smartacme-integration.ts - Verified SmartAcme handler access - Verified certificate manager creation 4. **Day 4: Documentation & Cleanup** 🔄 IN PROGRESS - ❌ Update all documentation - ❌ Clean up old files (certificate/ and port80/) - ❌ Final testing and validation ## Risk Mitigation 1. **Static Route Handler** - Already exists in the type system - Just needs implementation in route-connection-handler.ts - Low risk as it follows existing patterns 2. **Route Updates During Operation** - SmartProxy's updateRoutes() is already thread-safe - Sequential processing prevents race conditions - Challenge routes are added/removed atomically 3. **Port 80 Conflicts** - Priority-based routing ensures ACME routes match first - Path-based matching (`/.well-known/acme-challenge/*`) is specific - Other routes on port 80 won't interfere 4. **Error Recovery** - SmartAcme initialization failures are handled gracefully - Null checks prevent crashes if ACME isn't available - Routes continue to work without certificates 5. **Testing Strategy** - Test concurrent ACME challenges - Test route priority conflicts - Test certificate renewal during high traffic - Test the new configuration format only 6. **No Migration Path** - Breaking change is intentional - Old configurations must be manually updated - No compatibility shims or helpers provided