smartproxy/readme.plan.md

15 KiB

SmartProxy Development Plan

ACME Route Port Binding Intelligence Improvement

Problem Statement

Currently, SmartProxy has an issue with port binding conflicts between regular routes and ACME challenge routes. While SmartProxy is designed to support multiple routes sharing the same port (differentiated by host, path, etc.), there's a specific conflict when adding ACME challenge routes to a port that is already in use by other routes.

This results in the error: Port 80 is already in use for ACME challenges when SmartProxy tries to bind the ACME challenge route to a port that it's already using.

Root Cause Analysis

  1. Double Binding Attempt: SmartProxy tries to bind to port 80 twice - once for application routes and once for ACME challenge routes.
  2. Overlapping Route Updates: When adding a challenge route, it triggers a port binding operation without checking if the port is already bound.
  3. Naive Error Handling: The code detects EADDRINUSE but doesn't distinguish between external conflicts and internal conflicts.
  4. Port Binding Semantics: The port manager doesn't recognize that a port already bound by SmartProxy can be reused for additional routes.

Solution Architecture

We need a more intelligent approach to port binding that understands when a port can be shared between routes vs. when a new binding is needed:

  1. Port Binding Awareness: Track what ports are already bound by SmartProxy itself.
  2. Smart Route Updates: Only attempt to bind to ports that aren't already bound by SmartProxy.
  3. Route Merging Logic: When adding ACME challenge routes, merge them with existing routes on the same ports.
  4. Dynamic Port Management: Release port bindings when no routes are using them and rebind when needed.
  5. Improved Error Recovery: Handle port conflicts gracefully, with distinct handling for internal vs. external conflicts.

Implementation Plan

Phase 1: Improve Port Manager Intelligence

  • Enhance PortManager to distinguish between ports that need new bindings vs ports that can reuse existing bindings
  • Add an internal tracking mechanism to detect when a requested port is already bound internally
  • Modify port addition logic to skip binding operations for ports already bound by SmartProxy
  • Implement reference counting for port bindings to track how many routes use each port
  • Add logic to release port bindings when no routes are using them anymore
  • Update error handling to provide more context for port binding failures

Phase 2: Refine ACME Challenge Route Integration

  • Modify addChallengeRoute() to check if the port is already in use by SmartProxy
  • Ensure route updates don't trigger unnecessary port binding operations
  • Implement a merging strategy for ACME routes with existing routes on the same port
  • Add diagnostic logging to track route and port binding relationships

Phase 3: Enhance Proxy Route Management

  • Restructure route update process to group routes by port
  • Implement a more efficient route update mechanism that minimizes port binding operations
  • Develop port lifecycle management to track usage across route changes
  • Add validation to detect potential binding conflicts before attempting operations
  • Create a proper route dependency graph to understand the relationships between routes
  • Implement efficient detection of "orphaned" ports that no longer have associated routes

Phase 4: Improve Error Handling and Recovery

  • Enhance error messages to be more specific about the nature of port conflicts
  • Add recovery mechanisms for common port binding scenarios
  • Implement a fallback port selection strategy for ACME challenges
  • Create a more robust validation system to catch issues before they cause runtime errors

Detailed Technical Tasks

Phase 1: Improve Port Manager Intelligence

  1. Modify /ts/proxies/smart-proxy/port-manager.ts:

    • Add a new method isPortBoundBySmartProxy(port: number): boolean
    • Refactor addPort() to check if the port is already bound
    • Update updatePorts() to be more intelligent about which ports need binding
    • Add reference counting for port usage
  2. Implement Port Reference Counting:

    // Add to PortManager class
    private portRefCounts: Map<number, number> = new Map();
    
    public incrementPortRefCount(port: number): void {
      const currentCount = this.portRefCounts.get(port) || 0;
      this.portRefCounts.set(port, currentCount + 1);
      logger.log('debug', `Port ${port} reference count increased to ${currentCount + 1}`, { port, refCount: currentCount + 1 });
    }
    
    public decrementPortRefCount(port: number): number {
      const currentCount = this.portRefCounts.get(port) || 0;
      if (currentCount <= 0) {
        logger.log('warn', `Attempted to decrement reference count for port ${port} below zero`, { port });
        return 0;
      }
    
      const newCount = currentCount - 1;
      this.portRefCounts.set(port, newCount);
      logger.log('debug', `Port ${port} reference count decreased to ${newCount}`, { port, refCount: newCount });
      return newCount;
    }
    
    public getPortRefCount(port: number): number {
      return this.portRefCounts.get(port) || 0;
    }
    
  3. Port Binding Logic Enhancements:

    public async addPort(port: number): Promise<void> {
      // If already bound by this instance, just increment ref count and return
      if (this.servers.has(port)) {
        this.incrementPortRefCount(port);
        logger.log('debug', `Port ${port} is already bound by SmartProxy, reusing binding`, { port });
        return;
      }
    
      // Initialize ref count for new port
      this.portRefCounts.set(port, 1);
    
      // Continue with normal binding...
    }
    
    public async removePort(port: number): Promise<void> {
      // Decrement reference count
      const newCount = this.decrementPortRefCount(port);
    
      // If port is still in use by other routes, keep it
      if (newCount > 0) {
        logger.log('debug', `Port ${port} still in use by ${newCount} routes, keeping binding open`, { port, refCount: newCount });
        return;
      }
    
      // No more references, can actually close the port
      const server = this.servers.get(port);
      if (!server) {
        logger.log('warn', `Port ${port} not found in servers map`, { port });
        return;
      }
    
      // Continue with normal unbinding logic...
    }
    
  4. Add Smarter Port Conflict Detection:

    private isPortConflict(error: any): { isConflict: boolean; isExternal: boolean } {
      if (error.code !== 'EADDRINUSE') {
        return { isConflict: false, isExternal: false };
      }
    
      // Check if we already have this port
      const isBoundInternally = this.servers.has(Number(error.port));
      return { isConflict: true, isExternal: !isBoundInternally };
    }
    

Phase 2: Refine ACME Challenge Route Integration

  1. Modify /ts/proxies/smart-proxy/certificate-manager.ts:

    • Enhance addChallengeRoute() to be aware of existing port bindings
    • Add port verification before attempting to add challenge routes
  2. Smart Route Merging Logic:

    private async addChallengeRoute(): Promise<void> {
      // Check if route is already active
      if (this.challengeRouteActive) {
        return;
      }
    
      // Create challenge route
      const challengeRoute = this.challengeRoute;
      const challengePort = this.globalAcmeDefaults?.port || 80;
    
      // Check if port is already in use by another route
      const portAlreadyUsed = this.routes.some(r => 
        Array.isArray(r.match.ports) 
        ? r.match.ports.includes(challengePort)
        : r.match.ports === challengePort
      );
    
      if (portAlreadyUsed) {
        logger.log('info', `Port ${challengePort} is already used by an existing route, merging ACME challenge route`);
      }
    
      // Continue with route update...
    }
    
  3. Update Route Manager Communication:

    // Add this method to smart-proxy.ts
    private async addRouteWithoutRebinding(route: IRouteConfig): Promise<void> {
      // Add route to configuration without triggering a port rebind
      this.settings.routes.push(route);
      this.routeManager.updateRoutes(this.settings.routes);
    
      // Update HttpProxy if needed, but skip port binding updates
      if (this.httpProxyBridge.getHttpProxy()) {
        await this.httpProxyBridge.syncRoutesToHttpProxy(this.settings.routes);
      }
    }
    

Phase 3: Enhance Proxy Route Management

  1. Modify /ts/proxies/smart-proxy/smart-proxy.ts:

    • Refactor updateRoutes() to group routes by port
    • Implement incremental updates that preserve port bindings
    • Add orphaned port detection and cleanup
  2. Group Routes by Port:

    private groupRoutesByPort(routes: IRouteConfig[]): Map<number, IRouteConfig[]> {
      const portMap = new Map<number, IRouteConfig[]>();
    
      for (const route of routes) {
        const ports = Array.isArray(route.match.ports) 
          ? route.match.ports 
          : [route.match.ports];
    
        for (const port of ports) {
          if (!portMap.has(port)) {
            portMap.set(port, []);
          }
          portMap.get(port)!.push(route);
        }
      }
    
      return portMap;
    }
    
  3. Implement Port Usage Tracking:

    private updatePortUsageMap(routes: IRouteConfig[]): Map<number, Set<string>> {
      // Map of port -> Set of route names using that port
      const portUsage = new Map<number, Set<string>>();
    
      for (const route of routes) {
        const ports = Array.isArray(route.match.ports) 
          ? route.match.ports 
          : [route.match.ports];
    
        const routeName = route.name || `unnamed_${Math.random().toString(36).substring(2, 9)}`;
    
        for (const port of ports) {
          if (!portUsage.has(port)) {
            portUsage.set(port, new Set());
          }
          portUsage.get(port)!.add(routeName);
        }
      }
    
      return portUsage;
    }
    
    private findOrphanedPorts(oldUsage: Map<number, Set<string>>, newUsage: Map<number, Set<string>>): number[] {
      // Find ports that have no routes in new configuration
      const orphanedPorts: number[] = [];
    
      for (const [port, routes] of oldUsage.entries()) {
        if (!newUsage.has(port) || newUsage.get(port)!.size === 0) {
          orphanedPorts.push(port);
          logger.log('info', `Port ${port} no longer has any associated routes, will be released`, { port });
        }
      }
    
      return orphanedPorts;
    }
    
  4. Implement Incremental Update Logic:

    public async updateRoutesIncremental(newRoutes: IRouteConfig[]): Promise<void> {
      // Track port usage before and after update
      const oldPortUsage = this.updatePortUsageMap(this.settings.routes);
      const newPortUsage = this.updatePortUsageMap(newRoutes);
    
      // Find orphaned ports - ports that no longer have any routes
      const orphanedPorts = this.findOrphanedPorts(oldPortUsage, newPortUsage);
    
      // Ports that need new bindings - not in old configuration
      const newBindingPorts = [...newPortUsage.keys()].filter(p => !oldPortUsage.has(p));
    
      // Close orphaned ports
      if (orphanedPorts.length > 0) {
        logger.log('info', `Releasing ${orphanedPorts.length} orphaned ports: ${orphanedPorts.join(', ')}`, { ports: orphanedPorts });
        await this.portManager.removePorts(orphanedPorts);
      }
    
      // Bind to new ports
      if (newBindingPorts.length > 0) {
        logger.log('info', `Binding to ${newBindingPorts.length} new ports: ${newBindingPorts.join(', ')}`, { ports: newBindingPorts });
        await this.portManager.addPorts(newBindingPorts);
      }
    
      // Update route configuration
      this.settings.routes = newRoutes;
      this.routeManager.updateRoutes(newRoutes);
    
      // Update other components...
    }
    

Phase 4: Improve Error Handling and Recovery

  1. Enhance Error Reporting:

    private handlePortBindingError(port: number, error: any): void {
      if (error.code === 'EADDRINUSE') {
        const isInternalConflict = this.portManager.isPortBoundBySmartProxy(port);
        if (isInternalConflict) {
          logger.log('warn', `Port ${port} is already bound by SmartProxy. This is likely a route configuration issue.`, { port });
        } else {
          logger.log('error', `Port ${port} is in use by another application. Please choose a different port.`, { port });
        }
      } else {
        logger.log('error', `Failed to bind to port ${port}: ${error.message}`, { port, error });
      }
    }
    
  2. Implement ACME Port Fallback Strategy:

    private async selectAcmePort(): Promise<number> {
      const preferredPort = this.globalAcmeDefaults?.port || 80;
    
      // Check if preferred port is already bound internally
      if (this.portManager.isPortBoundBySmartProxy(preferredPort)) {
        // We can use it without a new binding
        return preferredPort;
      }
    
      // Try to bind to preferred port
      try {
        // Temporary test binding
        const server = plugins.net.createServer();
        await new Promise<void>((resolve, reject) => {
          server.listen(preferredPort, () => {
            server.close();
            resolve();
          }).on('error', reject);
        });
    
        // If we get here, port is available
        return preferredPort;
      } catch (error) {
        if (error.code === 'EADDRINUSE') {
          // Port is unavailable, try fallback ports
          for (const fallbackPort of [8080, 8081, 8082, 8083, 8084]) {
            try {
              // Test if we can bind to fallback
              const server = plugins.net.createServer();
              await new Promise<void>((resolve, reject) => {
                server.listen(fallbackPort, () => {
                  server.close();
                  resolve();
                }).on('error', reject);
              });
    
              logger.log('warn', `Primary ACME port ${preferredPort} is unavailable, using fallback port ${fallbackPort}`);
              return fallbackPort;
            } catch {
              // Try next fallback
            }
          }
        }
    
        // All attempts failed
        throw new Error(`Could not find an available port for ACME challenges`);
      }
    }
    

Testing Strategy

  1. Unit Tests:

    • Test port binding intelligence
    • Test route merging logic
    • Test error handling mechanisms
    • Test port reference counting
    • Test orphaned port detection and cleanup
  2. Integration Tests:

    • Test multiple routes on the same port
    • Test ACME challenges on ports with existing routes
    • Test dynamic route addition and removal
    • Test port lifecycle (bind → share → release)
    • Test various recovery scenarios
  3. Stress Tests:

    • Test rapid route updates
    • Test concurrent operations
    • Test large scale route changes (add/remove many at once)
    • Test frequent changes to see if ports are properly released
    • Test recovery from port conflicts

Release Plan

  1. 19.4.0 - Phase 1 & 2: Port Manager and ACME Route Improvements
  2. 19.5.0 - Phase 3: Enhanced Route Management
  3. 19.6.0 - Phase 4: Improved Error Handling and Recovery