smartproxy/readme.plan.md
2025-05-18 15:12:36 +00:00

41 KiB

ACME/Certificate Simplification Plan for SmartProxy

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

// 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';
import { AcmeClient } from './acme-client.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<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' : '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<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, {
        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<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) {
        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<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.CertManager {
  private store = new Map<string, any>();
  
  public async getCert(domain: string): Promise<any> {
    // 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<void> {
    // SmartAcme calls this after getting a cert
    // We ignore it since we handle storage ourselves
  }
  
  public async removeCert(domain: string): Promise<void> {
    // Not needed for our use case
  }
}

1.2 Create cert-store.ts

// 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<void> {
    await plugins.smartfile.fs.ensureDirectory(this.certDir);
  }
  
  public async getCertificate(routeName: string): Promise<ICertificateData | null> {
    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<void> {
    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<void> {
    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

// 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<string, string>;
  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<IStaticResponse>;  // 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

// 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<void> {
  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<number, string> = {
    200: 'OK',
    404: 'Not Found',
    500: 'Internal Server Error'
  };
  return statusTexts[status] || 'Unknown';
}

Phase 3: SmartProxy Integration

3.1 Update SmartProxy class

// 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<void> {
    // 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<void> {
    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<void> {
    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<void> {
    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

// 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<void> {
    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<void> {
    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<void> {
    if (!this.networkProxy) {
      throw new Error('NetworkProxy not initialized');
    }
    
    const proxySocket = new plugins.net.Socket();
    
    await new Promise<void>((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<void> {
    if (this.networkProxy) {
      await this.networkProxy.start();
    }
  }
  
  /**
   * Stop NetworkProxy
   */
  public async stop(): Promise<void> {
    if (this.networkProxy) {
      await this.networkProxy.stop();
      this.networkProxy = null;
    }
  }
}

Phase 4: Configuration Examples (No Migration)

4.1 New Configuration Format ONLY

// 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

## 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

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

// 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
```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

#!/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

{
  "dependencies": {
    // Remove if no longer needed elsewhere:
    // "@push.rocks/smartexpress": "x.x.x"
  }
}

Implementation Sequence

  1. Day 1: Core Implementation

    • Create SmartCertManager class
    • Create CertStore and AcmeClient
    • Update route types
  2. Day 2: Integration

    • Update SmartProxy to use SmartCertManager
    • Simplify NetworkProxyBridge
    • Remove old certificate system
  3. Day 3: Testing

    • Create new tests using new format only
    • No migration testing needed
    • Test all new functionality
  4. Day 4: Documentation & Cleanup

    • Update all documentation
    • Clean up old files
    • 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