Files
smartproxy/readme.plan.md
2025-07-12 21:58:46 +00:00

9.2 KiB

SmartProxy Implementation Plan

Feature: Custom Certificate Provision Function

Summary

This plan implements the certProvisionFunction feature that allows users to provide their own certificate generation logic. The function can either return a custom certificate or delegate back to Let's Encrypt by returning 'http01'.

Key Changes

  1. Add certProvisionFunction support to CertificateManager
  2. Modify provisionAcmeCertificate() to check custom function first
  3. Add certificate expiry parsing for custom certificates
  4. Support both initial provisioning and renewal
  5. Add fallback configuration option

Overview

Implement the certProvisionFunction callback that's defined in the interface but currently not implemented. This will allow users to provide custom certificate generation logic while maintaining backward compatibility with the existing Let's Encrypt integration.

Requirements

  1. The function should be called for any new certificate provisioning or renewal
  2. Must support returning custom certificates or falling back to Let's Encrypt
  3. Should integrate seamlessly with the existing certificate lifecycle
  4. Must maintain backward compatibility

Implementation Steps

1. Update Certificate Manager to Support Custom Provision Function

File: ts/proxies/smart-proxy/certificate-manager.ts

  • Add certProvisionFunction property to CertificateManager class
  • Pass the function from SmartProxy options during initialization
  • Modify provisionCertificate() method to check for custom function first

2. Implement Custom Certificate Provisioning Logic

Location: Modify provisionAcmeCertificate() method

private async provisionAcmeCertificate(
  route: IRouteConfig, 
  domains: string[]
): Promise<void> {
  const primaryDomain = domains[0];
  const routeName = route.name || primaryDomain;
  
  // Check for custom provision function first
  if (this.certProvisionFunction) {
    try {
      logger.log('info', `Attempting custom certificate provision for ${primaryDomain}`, { domain: primaryDomain });
      const result = await this.certProvisionFunction(primaryDomain);
      
      if (result === 'http01') {
        logger.log('info', `Custom function returned 'http01', falling back to Let's Encrypt for ${primaryDomain}`);
        // Continue with existing ACME logic below
      } else {
        // Use custom certificate
        const customCert = result as plugins.tsclass.network.ICert;
        
        // Convert to internal certificate format
        const certData: ICertificateData = {
          cert: customCert.cert,
          key: customCert.key,
          ca: customCert.ca || '',
          issueDate: new Date(),
          expiryDate: this.extractExpiryDate(customCert.cert)
        };
        
        // Store and apply certificate
        await this.certStore.saveCertificate(routeName, certData);
        await this.applyCertificate(primaryDomain, certData);
        this.updateCertStatus(routeName, 'valid', 'custom', certData);
        
        logger.log('info', `Custom certificate applied for ${primaryDomain}`, { 
          domain: primaryDomain,
          expiryDate: certData.expiryDate
        });
        return;
      }
    } catch (error) {
      logger.log('error', `Custom cert provision failed for ${primaryDomain}: ${error.message}`, {
        domain: primaryDomain,
        error: error.message
      });
      // Configuration option to control fallback behavior
      if (this.smartProxy.settings.certProvisionFallbackToAcme !== false) {
        logger.log('info', `Falling back to Let's Encrypt for ${primaryDomain}`);
      } else {
        throw error;
      }
    }
  }
  
  // Existing Let's Encrypt logic continues here...
  if (!this.smartAcme) {
    throw new Error('SmartAcme not initialized...');
  }
  // ... rest of existing code
}

3. Add Helper Method for Certificate Expiry Extraction

New method: extractExpiryDate()

  • Parse PEM certificate to extract expiry date
  • Use existing certificate parsing utilities
  • Handle parse errors gracefully
private extractExpiryDate(certPem: string): Date {
  try {
    // Use forge or similar library to parse certificate
    const cert = forge.pki.certificateFromPem(certPem);
    return cert.validity.notAfter;
  } catch (error) {
    // Default to 90 days if parsing fails
    return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
  }
}

4. Update SmartProxy Initialization

File: ts/proxies/smart-proxy/index.ts

  • Pass certProvisionFunction from options to CertificateManager
  • Validate function if provided

5. Add Type Safety and Validation

Tasks:

  • Validate returned certificate has required fields (cert, key, ca)
  • Check certificate validity dates
  • Ensure certificate matches requested domain

6. Update Certificate Renewal Logic

Location: checkAndRenewCertificates()

  • Ensure renewal checks work for both ACME and custom certificates
  • Custom certificates should go through the same provisionAcmeCertificate() path
  • The existing renewal logic already calls provisionCertificate() which will use our modified flow
// No changes needed here - the existing renewal logic will automatically
// use the custom provision function when calling provisionCertificate()
private async checkAndRenewCertificates(): Promise<void> {
  // Existing code already handles this correctly
  for (const route of routes) {
    if (this.shouldRenewCertificate(cert, renewThreshold)) {
      // This will call provisionCertificate -> provisionAcmeCertificate
      // which now includes our custom function check
      await this.provisionCertificate(route);
    }
  }
}

7. Add Integration Tests

File: test/test.certificate-provision.ts

  • Test custom certificate provision
  • Test fallback to Let's Encrypt ('http01' return)
  • Test error handling
  • Test renewal with custom function

8. Update Documentation

Files:

  • Update interface documentation
  • Add examples to README
  • Document ICert structure requirements

API Design

// Example usage
const proxy = new SmartProxy({
  certProvisionFunction: async (domain: string) => {
    // Option 1: Return custom certificate
    const customCert = await myCustomCA.generateCert(domain);
    return {
      cert: customCert.certificate,
      key: customCert.privateKey,
      ca: customCert.chain
    };
    
    // Option 2: Use Let's Encrypt for certain domains
    if (domain.endsWith('.internal')) {
      return customCert;
    }
    return 'http01'; // Fallback to Let's Encrypt
  },
  certProvisionFallbackToAcme: true, // Default: true
  routes: [...]
});

Configuration Options to Add

interface ISmartProxyOptions {
  // Existing options...
  
  // Custom certificate provision function
  certProvisionFunction?: (domain: string) => Promise<TSmartProxyCertProvisionObject>;
  
  // Whether to fallback to ACME if custom provision fails
  certProvisionFallbackToAcme?: boolean; // Default: true
}

Error Handling Strategy

  1. Custom Function Errors:

    • Log detailed error with domain context
    • Option A: Fallback to Let's Encrypt (safer)
    • Option B: Fail certificate provisioning (stricter)
    • Make this configurable via option?
  2. Invalid Certificate Returns:

    • Validate certificate structure
    • Check expiry dates
    • Verify domain match

Testing Plan

  1. Unit Tests:

    • Mock certProvisionFunction returns
    • Test validation logic
    • Test error scenarios
  2. Integration Tests:

    • Real certificate generation
    • Renewal cycle testing
    • Mixed custom/Let's Encrypt scenarios

Backward Compatibility

  • If no certProvisionFunction provided, behavior unchanged
  • Existing routes with 'auto' certificates continue using Let's Encrypt
  • No breaking changes to existing API

Future Enhancements

  1. Per-Route Custom Functions:

    • Allow different provision functions per route
    • Override global function at route level
  2. Certificate Events:

    • Emit events for custom cert provisioning
    • Allow monitoring/logging hooks
  3. Async Certificate Updates:

    • Support updating certificates outside renewal cycle
    • Hot-reload certificates without restart

Implementation Notes

  1. Certificate Status Tracking:

    • The updateCertStatus() method needs to support a new type: 'custom'
    • Current types are 'acme' and 'static'
    • This helps distinguish custom certificates in monitoring/logs
  2. Certificate Store Integration:

    • Custom certificates are stored the same way as ACME certificates
    • They participate in the same renewal cycle
    • The store handles persistence across restarts
  3. Existing Methods to Reuse:

    • applyCertificate() - Already handles applying certs to routes
    • isCertificateValid() - Can validate custom certificates
    • certStore.saveCertificate() - Handles storage

Implementation Priority

  1. Core functionality (steps 1-3)
  2. Type safety and validation (step 5)
  3. Renewal support (step 6)
  4. Tests (step 7)
  5. Documentation (step 8)

Estimated Effort

  • Core implementation: 4-6 hours
  • Testing: 2-3 hours
  • Documentation: 1 hour
  • Total: ~8-10 hours