BREAKING CHANGE(certificates): Remove legacy certificate modules and Port80Handler; update documentation and route configurations to use SmartCertManager for certificate management.
This commit is contained in:
		| @@ -1,5 +1,13 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## 2025-05-18 - 19.0.0 - BREAKING CHANGE(certificates) | ||||
| Remove legacy certificate modules and Port80Handler; update documentation and route configurations to use SmartCertManager for certificate management. | ||||
|  | ||||
| - Removed deprecated files under ts/certificate (acme, events, storage, providers) and ts/http/port80. | ||||
| - Updated readme.md and docs/certificate-management.md to reflect new SmartCertManager integration and removal of Port80Handler. | ||||
| - Updated route types and models to remove legacy certificate types and references to Port80Handler. | ||||
| - Bumped major version to reflect breaking changes in certificate management. | ||||
|  | ||||
| ## 2025-05-18 - 18.2.0 - feat(smartproxy/certificate) | ||||
| Integrate HTTP-01 challenge handler into ACME certificate provisioning workflow | ||||
|  | ||||
|   | ||||
							
								
								
									
										217
									
								
								docs/certificate-management.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										217
									
								
								docs/certificate-management.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,217 @@ | ||||
| # Certificate Management in SmartProxy v18+ | ||||
|  | ||||
| ## Overview | ||||
|  | ||||
| SmartProxy v18+ introduces a simplified certificate management system using the new `SmartCertManager` class. This replaces the previous `Port80Handler` and multiple certificate-related modules with a unified, route-based approach. | ||||
|  | ||||
| ## Key Changes from Previous Versions | ||||
|  | ||||
| - **No backward compatibility**: This is a clean break from the legacy certificate system | ||||
| - **No separate Port80Handler**: ACME challenges are now handled as regular SmartProxy routes | ||||
| - **Unified route-based configuration**: Certificates are configured directly in route definitions | ||||
| - **Direct integration with @push.rocks/smartacme**: Leverages SmartAcme's built-in capabilities | ||||
|  | ||||
| ## Configuration | ||||
|  | ||||
| ### Route-Level Certificate Configuration | ||||
|  | ||||
| Certificates are now configured at the route level using the `tls` property: | ||||
|  | ||||
| ```typescript | ||||
| const route: IRouteConfig = { | ||||
|   name: 'secure-website', | ||||
|   match: { | ||||
|     ports: 443, | ||||
|     domains: ['example.com', 'www.example.com'] | ||||
|   }, | ||||
|   action: { | ||||
|     type: 'forward', | ||||
|     target: { host: 'localhost', port: 8080 }, | ||||
|     tls: { | ||||
|       mode: 'terminate', | ||||
|       certificate: 'auto',  // Use ACME (Let's Encrypt) | ||||
|       acme: { | ||||
|         email: 'admin@example.com', | ||||
|         useProduction: true, | ||||
|         renewBeforeDays: 30 | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| }; | ||||
| ``` | ||||
|  | ||||
| ### Static Certificate Configuration | ||||
|  | ||||
| For manually managed certificates: | ||||
|  | ||||
| ```typescript | ||||
| const route: IRouteConfig = { | ||||
|   name: 'api-endpoint', | ||||
|   match: { | ||||
|     ports: 443, | ||||
|     domains: 'api.example.com' | ||||
|   }, | ||||
|   action: { | ||||
|     type: 'forward', | ||||
|     target: { host: 'localhost', port: 9000 }, | ||||
|     tls: { | ||||
|       mode: 'terminate', | ||||
|       certificate: { | ||||
|         certFile: './certs/api.crt', | ||||
|         keyFile: './certs/api.key', | ||||
|         ca: '...' // Optional CA chain | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| }; | ||||
| ``` | ||||
|  | ||||
| ## TLS Modes | ||||
|  | ||||
| SmartProxy supports three TLS modes: | ||||
|  | ||||
| 1. **terminate**: Decrypt TLS at the proxy and forward plain HTTP | ||||
| 2. **passthrough**: Pass encrypted TLS traffic directly to the backend | ||||
| 3. **terminate-and-reencrypt**: Decrypt at proxy, then re-encrypt to backend | ||||
|  | ||||
| ## Certificate Storage | ||||
|  | ||||
| Certificates are stored in the `./certs` directory by default: | ||||
|  | ||||
| ``` | ||||
| ./certs/ | ||||
| ├── route-name/ | ||||
| │   ├── cert.pem | ||||
| │   ├── key.pem | ||||
| │   ├── ca.pem (if available) | ||||
| │   └── meta.json | ||||
| ``` | ||||
|  | ||||
| ## ACME Integration | ||||
|  | ||||
| ### How It Works | ||||
|  | ||||
| 1. SmartProxy creates a high-priority route for ACME challenges | ||||
| 2. When ACME server makes requests to `/.well-known/acme-challenge/*`, SmartProxy handles them automatically | ||||
| 3. Certificates are obtained and stored locally | ||||
| 4. Automatic renewal checks every 12 hours | ||||
|  | ||||
| ### Configuration Options | ||||
|  | ||||
| ```typescript | ||||
| export interface IRouteAcme { | ||||
|   email: string;                    // Contact email for ACME account | ||||
|   useProduction?: boolean;          // Use production servers (default: false) | ||||
|   challengePort?: number;           // Port for HTTP-01 challenges (default: 80) | ||||
|   renewBeforeDays?: number;         // Days before expiry to renew (default: 30) | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## Advanced Usage | ||||
|  | ||||
| ### Manual Certificate Operations | ||||
|  | ||||
| ```typescript | ||||
| // Get certificate status | ||||
| const status = proxy.getCertificateStatus('route-name'); | ||||
| console.log(status); | ||||
| // { | ||||
| //   domain: 'example.com', | ||||
| //   status: 'valid', | ||||
| //   source: 'acme', | ||||
| //   expiryDate: Date, | ||||
| //   issueDate: Date | ||||
| // } | ||||
|  | ||||
| // Force certificate renewal | ||||
| await proxy.renewCertificate('route-name'); | ||||
|  | ||||
| // Manually provision a certificate | ||||
| await proxy.provisionCertificate('route-name'); | ||||
| ``` | ||||
|  | ||||
| ### Events | ||||
|  | ||||
| SmartProxy emits certificate-related events: | ||||
|  | ||||
| ```typescript | ||||
| proxy.on('certificate:issued', (event) => { | ||||
|   console.log(`New certificate for ${event.domain}`); | ||||
| }); | ||||
|  | ||||
| proxy.on('certificate:renewed', (event) => { | ||||
|   console.log(`Certificate renewed for ${event.domain}`); | ||||
| }); | ||||
|  | ||||
| proxy.on('certificate:expiring', (event) => { | ||||
|   console.log(`Certificate expiring soon for ${event.domain}`); | ||||
| }); | ||||
| ``` | ||||
|  | ||||
| ## Migration from Previous Versions | ||||
|  | ||||
| ### Before (v17 and earlier) | ||||
|  | ||||
| ```typescript | ||||
| // Old approach with Port80Handler | ||||
| const smartproxy = new SmartProxy({ | ||||
|   port: 443, | ||||
|   acme: { | ||||
|     enabled: true, | ||||
|     accountEmail: 'admin@example.com', | ||||
|     // ... other ACME options | ||||
|   } | ||||
| }); | ||||
|  | ||||
| // Certificate provisioning was automatic or via certProvisionFunction | ||||
| ``` | ||||
|  | ||||
| ### After (v18+) | ||||
|  | ||||
| ```typescript | ||||
| // New approach with route-based configuration | ||||
| const smartproxy = new SmartProxy({ | ||||
|   routes: [{ | ||||
|     match: { ports: 443, domains: 'example.com' }, | ||||
|     action: { | ||||
|       type: 'forward', | ||||
|       target: { host: 'localhost', port: 8080 }, | ||||
|       tls: { | ||||
|         mode: 'terminate', | ||||
|         certificate: 'auto', | ||||
|         acme: { | ||||
|           email: 'admin@example.com', | ||||
|           useProduction: true | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }] | ||||
| }); | ||||
| ``` | ||||
|  | ||||
| ## Troubleshooting | ||||
|  | ||||
| ### Common Issues | ||||
|  | ||||
| 1. **Certificate not provisioning**: Ensure port 80 is accessible for ACME challenges | ||||
| 2. **ACME rate limits**: Use staging environment for testing | ||||
| 3. **Permission errors**: Ensure the certificate directory is writable | ||||
|  | ||||
| ### Debug Mode | ||||
|  | ||||
| Enable detailed logging to troubleshoot certificate issues: | ||||
|  | ||||
| ```typescript | ||||
| const proxy = new SmartProxy({ | ||||
|   enableDetailedLogging: true, | ||||
|   // ... other options | ||||
| }); | ||||
| ``` | ||||
|  | ||||
| ## Best Practices | ||||
|  | ||||
| 1. **Always test with staging ACME servers first** | ||||
| 2. **Set up monitoring for certificate expiration** | ||||
| 3. **Use meaningful route names for easier certificate management** | ||||
| 4. **Store static certificates securely with appropriate permissions** | ||||
| 5. **Implement certificate status monitoring in production** | ||||
							
								
								
									
										18
									
								
								readme.md
									
									
									
									
									
								
							
							
						
						
									
										18
									
								
								readme.md
									
									
									
									
									
								
							| @@ -21,10 +21,10 @@ SmartProxy has been restructured using a modern, modular architecture with a uni | ||||
| │   ├── /models               # Data models and interfaces | ||||
| │   ├── /utils                # Shared utilities (IP validation, logging, etc.) | ||||
| │   └── /events               # Common event definitions | ||||
| ├── /certificate              # Certificate management | ||||
| │   ├── /acme                 # ACME-specific functionality | ||||
| │   ├── /providers            # Certificate providers (static, ACME) | ||||
| │   └── /storage              # Certificate storage mechanisms | ||||
| ├── /certificate              # Certificate management (deprecated in v18+) | ||||
| │   ├── /acme                 # Moved to SmartCertManager | ||||
| │   ├── /providers            # Now integrated in route configuration  | ||||
| │   └── /storage              # Now uses CertStore | ||||
| ├── /forwarding               # Forwarding system | ||||
| │   ├── /handlers             # Various forwarding handlers | ||||
| │   │   ├── base-handler.ts   # Abstract base handler | ||||
| @@ -37,6 +37,8 @@ SmartProxy has been restructured using a modern, modular architecture with a uni | ||||
| │   │   ├── /models           # SmartProxy-specific interfaces | ||||
| │   │   │   ├── route-types.ts # Route-based configuration types | ||||
| │   │   │   └── interfaces.ts  # SmartProxy interfaces | ||||
| │   │   ├── certificate-manager.ts # SmartCertManager (new in v18+) | ||||
| │   │   ├── cert-store.ts     # Certificate file storage | ||||
| │   │   ├── route-helpers.ts  # Helper functions for creating routes | ||||
| │   │   ├── route-manager.ts  # Route management system | ||||
| │   │   ├── smart-proxy.ts    # Main SmartProxy class | ||||
| @@ -47,7 +49,7 @@ SmartProxy has been restructured using a modern, modular architecture with a uni | ||||
| │   ├── /sni                  # SNI handling components | ||||
| │   └── /alerts               # TLS alerts system | ||||
| └── /http                     # HTTP-specific functionality | ||||
|     ├── /port80               # Port80Handler components | ||||
|     ├── /port80               # Port80Handler (removed in v18+) | ||||
|     ├── /router               # HTTP routing system | ||||
|     └── /redirects            # Redirect handlers | ||||
| ``` | ||||
| @@ -1411,6 +1413,12 @@ NetworkProxy now supports full route-based configuration including: | ||||
| - `useIPSets` (boolean, default true) | ||||
| - `qos`, `netProxyIntegration` (objects) | ||||
|  | ||||
| ## Documentation | ||||
|  | ||||
| - [Certificate Management](docs/certificate-management.md) - Detailed guide on certificate provisioning and ACME integration | ||||
| - [Port Handling](docs/porthandling.md) - Dynamic port management and runtime configuration | ||||
| - [NFTables Integration](docs/nftables-integration.md) - High-performance kernel-level forwarding | ||||
|  | ||||
| ## Troubleshooting | ||||
|  | ||||
| ### SmartProxy | ||||
|   | ||||
| @@ -1,5 +1,22 @@ | ||||
| # 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` | ||||
|  | ||||
| @@ -71,14 +88,13 @@ ts/proxies/smart-proxy/ | ||||
|  | ||||
| ### Phase 1: Create SmartCertManager | ||||
|  | ||||
| #### 1.1 Create certificate-manager.ts | ||||
| #### 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'; | ||||
| import { AcmeClient } from './acme-client.js'; | ||||
|  | ||||
| export interface ICertStatus { | ||||
|   domain: string; | ||||
| @@ -578,7 +594,7 @@ class InMemoryCertManager implements plugins.smartacme.CertManager { | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### 1.2 Create cert-store.ts | ||||
| #### 1.2 Create cert-store.ts ✅ COMPLETED | ||||
| ```typescript | ||||
| // ts/proxies/smart-proxy/cert-store.ts | ||||
| import * as plugins from '../../plugins.js'; | ||||
| @@ -675,7 +691,7 @@ export class CertStore { | ||||
|  | ||||
| ### Phase 2: Update Route Types and Handler | ||||
|  | ||||
| #### 2.1 Update route-types.ts | ||||
| #### 2.1 Update route-types.ts ✅ COMPLETED | ||||
| ```typescript | ||||
| // Add to ts/proxies/smart-proxy/models/route-types.ts | ||||
|  | ||||
| @@ -742,7 +758,7 @@ export interface IRouteTls { | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### 2.2 Add Static Route Handler | ||||
| #### 2.2 Add Static Route Handler ✅ COMPLETED | ||||
| ```typescript | ||||
| // Add to ts/proxies/smart-proxy/route-connection-handler.ts | ||||
|  | ||||
| @@ -839,7 +855,7 @@ function getStatusText(status: number): string { | ||||
|  | ||||
| ### Phase 3: SmartProxy Integration | ||||
|  | ||||
| #### 3.1 Update SmartProxy class | ||||
| #### 3.1 Update SmartProxy class ✅ COMPLETED | ||||
| ```typescript | ||||
| // Changes to ts/proxies/smart-proxy/smart-proxy.ts | ||||
|  | ||||
| @@ -1017,7 +1033,7 @@ export class SmartProxy extends plugins.EventEmitter { | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### 3.2 Simplify NetworkProxyBridge | ||||
| #### 3.2 Simplify NetworkProxyBridge ✅ COMPLETED | ||||
| ```typescript | ||||
| // Simplified ts/proxies/smart-proxy/network-proxy-bridge.ts | ||||
|  | ||||
| @@ -1323,7 +1339,7 @@ Certificates are stored in the `./certs` directory by default: | ||||
|  | ||||
| ### Phase 5: Update HTTP Module | ||||
|  | ||||
| #### 5.1 Update http/index.ts | ||||
| #### 5.1 Update http/index.ts ✅ COMPLETED | ||||
| ```typescript | ||||
| // ts/http/index.ts | ||||
| /** | ||||
| @@ -1388,25 +1404,26 @@ The simplification leverages SmartProxy's existing capabilities rather than rein | ||||
|  | ||||
| ## Implementation Sequence | ||||
|  | ||||
| 1. **Day 1: Core Implementation** | ||||
| 1. **Day 1: Core Implementation** ✅ COMPLETED | ||||
|    - Create SmartCertManager class | ||||
|    - Create CertStore and AcmeClient | ||||
|    - Create CertStore | ||||
|    - Update route types | ||||
|    - Integrated with SmartAcme's built-in handlers | ||||
|  | ||||
| 2. **Day 2: Integration** | ||||
| 2. **Day 2: Integration** ✅ COMPLETED | ||||
|    - Update SmartProxy to use SmartCertManager | ||||
|    - Simplify NetworkProxyBridge | ||||
|    - Remove old certificate system | ||||
|    - Update HTTP index.ts | ||||
|  | ||||
| 3. **Day 3: Testing** | ||||
|    - Create new tests using new format only | ||||
|    - No migration testing needed | ||||
|    - Test all new functionality | ||||
| 3. **Day 3: Testing** ✅ COMPLETED | ||||
|    - Created test.smartacme-integration.ts | ||||
|    - Verified SmartAcme handler access | ||||
|    - Verified certificate manager creation | ||||
|  | ||||
| 4. **Day 4: Documentation & Cleanup** | ||||
|    - Update all documentation | ||||
|    - Clean up old files | ||||
|    - Final testing and validation | ||||
| 4. **Day 4: Documentation & Cleanup** 🔄 IN PROGRESS | ||||
|    - ❌ Update all documentation   | ||||
|    - ❌ Clean up old files (certificate/ and port80/) | ||||
|    - ❌ Final testing and validation | ||||
|  | ||||
| ## Risk Mitigation | ||||
|  | ||||
|   | ||||
| @@ -3,6 +3,6 @@ | ||||
|  */ | ||||
| export const commitinfo = { | ||||
|   name: '@push.rocks/smartproxy', | ||||
|   version: '18.2.0', | ||||
|   version: '19.0.0', | ||||
|   description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' | ||||
| } | ||||
|   | ||||
| @@ -1,48 +0,0 @@ | ||||
| import * as fs from 'fs'; | ||||
| import * as path from 'path'; | ||||
| import type { IAcmeOptions } from '../models/certificate-types.js'; | ||||
| import { ensureCertificateDirectory } from '../utils/certificate-helpers.js'; | ||||
| // We'll need to update this import when we move the Port80Handler | ||||
| import { Port80Handler } from '../../http/port80/port80-handler.js'; | ||||
|  | ||||
| /** | ||||
|  * Factory to create a Port80Handler with common setup. | ||||
|  * Ensures the certificate store directory exists and instantiates the handler. | ||||
|  * @param options Port80Handler configuration options | ||||
|  * @returns A new Port80Handler instance | ||||
|  */ | ||||
| export function buildPort80Handler( | ||||
|   options: IAcmeOptions | ||||
| ): Port80Handler { | ||||
|   if (options.certificateStore) { | ||||
|     ensureCertificateDirectory(options.certificateStore); | ||||
|     console.log(`Ensured certificate store directory: ${options.certificateStore}`); | ||||
|   } | ||||
|   return new Port80Handler(options); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Creates default ACME options with sensible defaults | ||||
|  * @param email Account email for ACME provider | ||||
|  * @param certificateStore Path to store certificates | ||||
|  * @param useProduction Whether to use production ACME servers | ||||
|  * @returns Configured ACME options | ||||
|  */ | ||||
| export function createDefaultAcmeOptions( | ||||
|   email: string, | ||||
|   certificateStore: string, | ||||
|   useProduction: boolean = false | ||||
| ): IAcmeOptions { | ||||
|   return { | ||||
|     accountEmail: email, | ||||
|     enabled: true, | ||||
|     port: 80, | ||||
|     useProduction, | ||||
|     httpsRedirectPort: 443, | ||||
|     renewThresholdDays: 30, | ||||
|     renewCheckIntervalHours: 24, | ||||
|     autoRenew: true, | ||||
|     certificateStore, | ||||
|     skipConfiguredCerts: false | ||||
|   }; | ||||
| } | ||||
| @@ -1,110 +0,0 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import type { IAcmeOptions, ICertificateData } from '../models/certificate-types.js'; | ||||
| import { CertificateEvents } from '../events/certificate-events.js'; | ||||
|  | ||||
| /** | ||||
|  * Manages ACME challenges and certificate validation | ||||
|  */ | ||||
| export class AcmeChallengeHandler extends plugins.EventEmitter { | ||||
|   private options: IAcmeOptions; | ||||
|   private client: any; // ACME client from plugins | ||||
|   private pendingChallenges: Map<string, any>; | ||||
|  | ||||
|   /** | ||||
|    * Creates a new ACME challenge handler | ||||
|    * @param options ACME configuration options | ||||
|    */ | ||||
|   constructor(options: IAcmeOptions) { | ||||
|     super(); | ||||
|     this.options = options; | ||||
|     this.pendingChallenges = new Map(); | ||||
|  | ||||
|     // Initialize ACME client if needed | ||||
|     // This is just a placeholder implementation since we don't use the actual | ||||
|     // client directly in this implementation - it's handled by Port80Handler | ||||
|     this.client = null; | ||||
|     console.log('Created challenge handler with options:', | ||||
|       options.accountEmail, | ||||
|       options.useProduction ? 'production' : 'staging' | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Gets or creates the ACME account key | ||||
|    */ | ||||
|   private getAccountKey(): Buffer { | ||||
|     // Implementation details would depend on plugin requirements | ||||
|     // This is a simplified version | ||||
|     if (!this.options.certificateStore) { | ||||
|       throw new Error('Certificate store is required for ACME challenges'); | ||||
|     } | ||||
|      | ||||
|     // This is just a placeholder - actual implementation would check for | ||||
|     // existing account key and create one if needed | ||||
|     return Buffer.from('account-key-placeholder'); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Validates a domain using HTTP-01 challenge | ||||
|    * @param domain Domain to validate | ||||
|    * @param challengeToken ACME challenge token | ||||
|    * @param keyAuthorization Key authorization for the challenge | ||||
|    */ | ||||
|   public async handleHttpChallenge( | ||||
|     domain: string, | ||||
|     challengeToken: string, | ||||
|     keyAuthorization: string | ||||
|   ): Promise<void> { | ||||
|     // Store challenge for response | ||||
|     this.pendingChallenges.set(challengeToken, keyAuthorization); | ||||
|      | ||||
|     try { | ||||
|       // Wait for challenge validation - this would normally be handled by the ACME client | ||||
|       await new Promise(resolve => setTimeout(resolve, 1000)); | ||||
|       this.emit(CertificateEvents.CERTIFICATE_ISSUED, { | ||||
|         domain, | ||||
|         success: true | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       this.emit(CertificateEvents.CERTIFICATE_FAILED, { | ||||
|         domain, | ||||
|         error: error instanceof Error ? error.message : String(error), | ||||
|         isRenewal: false | ||||
|       }); | ||||
|       throw error; | ||||
|     } finally { | ||||
|       // Clean up the challenge | ||||
|       this.pendingChallenges.delete(challengeToken); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Responds to an HTTP-01 challenge request | ||||
|    * @param token Challenge token from the request path | ||||
|    * @returns The key authorization if found | ||||
|    */ | ||||
|   public getChallengeResponse(token: string): string | null { | ||||
|     return this.pendingChallenges.get(token) || null; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Checks if a request path is an ACME challenge | ||||
|    * @param path Request path | ||||
|    * @returns True if this is an ACME challenge request | ||||
|    */ | ||||
|   public isAcmeChallenge(path: string): boolean { | ||||
|     return path.startsWith('/.well-known/acme-challenge/'); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Extracts the challenge token from an ACME challenge path | ||||
|    * @param path Request path | ||||
|    * @returns The challenge token if valid | ||||
|    */ | ||||
|   public extractChallengeToken(path: string): string | null { | ||||
|     if (!this.isAcmeChallenge(path)) return null; | ||||
|      | ||||
|     const parts = path.split('/'); | ||||
|     return parts[parts.length - 1] || null; | ||||
|   } | ||||
| } | ||||
| @@ -1,3 +0,0 @@ | ||||
| /** | ||||
|  * ACME certificate provisioning | ||||
|  */ | ||||
| @@ -1,36 +0,0 @@ | ||||
| /** | ||||
|  * Certificate-related events emitted by certificate management components | ||||
|  */ | ||||
| export enum CertificateEvents { | ||||
|   CERTIFICATE_ISSUED = 'certificate-issued', | ||||
|   CERTIFICATE_RENEWED = 'certificate-renewed', | ||||
|   CERTIFICATE_FAILED = 'certificate-failed', | ||||
|   CERTIFICATE_EXPIRING = 'certificate-expiring', | ||||
|   CERTIFICATE_APPLIED = 'certificate-applied', | ||||
|   // Events moved from Port80Handler for compatibility | ||||
|   MANAGER_STARTED = 'manager-started', | ||||
|   MANAGER_STOPPED = 'manager-stopped', | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Port80Handler-specific events including certificate-related ones | ||||
|  * @deprecated Use CertificateEvents and HttpEvents instead | ||||
|  */ | ||||
| export enum Port80HandlerEvents { | ||||
|   CERTIFICATE_ISSUED = 'certificate-issued', | ||||
|   CERTIFICATE_RENEWED = 'certificate-renewed', | ||||
|   CERTIFICATE_FAILED = 'certificate-failed', | ||||
|   CERTIFICATE_EXPIRING = 'certificate-expiring', | ||||
|   MANAGER_STARTED = 'manager-started', | ||||
|   MANAGER_STOPPED = 'manager-stopped', | ||||
|   REQUEST_FORWARDED = 'request-forwarded', | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Certificate provider events | ||||
|  */ | ||||
| export enum CertProvisionerEvents { | ||||
|   CERTIFICATE_ISSUED = 'certificate', | ||||
|   CERTIFICATE_RENEWED = 'certificate', | ||||
|   CERTIFICATE_FAILED = 'certificate-failed' | ||||
| } | ||||
| @@ -1,75 +0,0 @@ | ||||
| /** | ||||
|  * Certificate management module for SmartProxy | ||||
|  * Provides certificate provisioning, storage, and management capabilities | ||||
|  */ | ||||
|  | ||||
| // Certificate types and models | ||||
| export * from './models/certificate-types.js'; | ||||
|  | ||||
| // Certificate events | ||||
| export * from './events/certificate-events.js'; | ||||
|  | ||||
| // Certificate providers | ||||
| export * from './providers/cert-provisioner.js'; | ||||
|  | ||||
| // ACME related exports | ||||
| export * from './acme/acme-factory.js'; | ||||
| export * from './acme/challenge-handler.js'; | ||||
|  | ||||
| // Certificate utilities | ||||
| export * from './utils/certificate-helpers.js'; | ||||
|  | ||||
| // Certificate storage | ||||
| export * from './storage/file-storage.js'; | ||||
|  | ||||
| // Convenience function to create a certificate provisioner with common settings | ||||
| import { CertProvisioner } from './providers/cert-provisioner.js'; | ||||
| import type { TCertProvisionObject } from './providers/cert-provisioner.js'; | ||||
| import { buildPort80Handler } from './acme/acme-factory.js'; | ||||
| import type { IAcmeOptions, IRouteForwardConfig } from './models/certificate-types.js'; | ||||
| import type { IRouteConfig } from '../proxies/smart-proxy/models/route-types.js'; | ||||
|  | ||||
| /** | ||||
|  * Interface for NetworkProxyBridge used by CertProvisioner | ||||
|  */ | ||||
| interface ICertNetworkProxyBridge { | ||||
|   applyExternalCertificate(certData: any): void; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Creates a complete certificate provisioning system with default settings | ||||
|  * @param routeConfigs Route configurations that may need certificates | ||||
|  * @param acmeOptions ACME options for certificate provisioning | ||||
|  * @param networkProxyBridge Bridge to apply certificates to network proxy | ||||
|  * @param certProvider Optional custom certificate provider | ||||
|  * @returns Configured CertProvisioner | ||||
|  */ | ||||
| export function createCertificateProvisioner( | ||||
|   routeConfigs: IRouteConfig[], | ||||
|   acmeOptions: IAcmeOptions, | ||||
|   networkProxyBridge: ICertNetworkProxyBridge, | ||||
|   certProvider?: (domain: string) => Promise<TCertProvisionObject> | ||||
| ): CertProvisioner { | ||||
|   // Build the Port80Handler for ACME challenges | ||||
|   const port80Handler = buildPort80Handler(acmeOptions); | ||||
|  | ||||
|   // Extract ACME-specific configuration | ||||
|   const { | ||||
|     renewThresholdDays = 30, | ||||
|     renewCheckIntervalHours = 24, | ||||
|     autoRenew = true, | ||||
|     routeForwards = [] | ||||
|   } = acmeOptions; | ||||
|  | ||||
|   // Create and return the certificate provisioner | ||||
|   return new CertProvisioner( | ||||
|     routeConfigs, | ||||
|     port80Handler, | ||||
|     networkProxyBridge, | ||||
|     certProvider, | ||||
|     renewThresholdDays, | ||||
|     renewCheckIntervalHours, | ||||
|     autoRenew, | ||||
|     routeForwards | ||||
|   ); | ||||
| } | ||||
| @@ -1,109 +0,0 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js'; | ||||
|  | ||||
| /** | ||||
|  * Certificate data structure containing all necessary information | ||||
|  * about a certificate | ||||
|  */ | ||||
| export interface ICertificateData { | ||||
|   domain: string; | ||||
|   certificate: string; | ||||
|   privateKey: string; | ||||
|   expiryDate: Date; | ||||
|   // Optional source and renewal information for event emissions | ||||
|   source?: 'static' | 'http01' | 'dns01'; | ||||
|   isRenewal?: boolean; | ||||
|   // Reference to the route that requested this certificate (if available) | ||||
|   routeReference?: { | ||||
|     routeId?: string; | ||||
|     routeName?: string; | ||||
|   }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Certificates pair (private and public keys) | ||||
|  */ | ||||
| export interface ICertificates { | ||||
|   privateKey: string; | ||||
|   publicKey: string; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Certificate failure payload type | ||||
|  */ | ||||
| export interface ICertificateFailure { | ||||
|   domain: string; | ||||
|   error: string; | ||||
|   isRenewal: boolean; | ||||
|   routeReference?: { | ||||
|     routeId?: string; | ||||
|     routeName?: string; | ||||
|   }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Certificate expiry payload type | ||||
|  */ | ||||
| export interface ICertificateExpiring { | ||||
|   domain: string; | ||||
|   expiryDate: Date; | ||||
|   daysRemaining: number; | ||||
|   routeReference?: { | ||||
|     routeId?: string; | ||||
|     routeName?: string; | ||||
|   }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Route-specific forwarding configuration for ACME challenges | ||||
|  */ | ||||
| export interface IRouteForwardConfig { | ||||
|   domain: string; | ||||
|   target: { | ||||
|     host: string; | ||||
|     port: number; | ||||
|   }; | ||||
|   sslRedirect?: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Domain configuration options for Port80Handler | ||||
|  * | ||||
|  * This is used internally by the Port80Handler to manage domains | ||||
|  * but will eventually be replaced with route-based options. | ||||
|  */ | ||||
| export interface IDomainOptions { | ||||
|   domainName: string; | ||||
|   sslRedirect: boolean;   // if true redirects the request to port 443 | ||||
|   acmeMaintenance: boolean; // tries to always have a valid cert for this domain | ||||
|   forward?: { | ||||
|     ip: string; | ||||
|     port: number; | ||||
|   }; // forwards all http requests to that target | ||||
|   acmeForward?: { | ||||
|     ip: string; | ||||
|     port: number; | ||||
|   }; // forwards letsencrypt requests to this config | ||||
|   routeReference?: { | ||||
|     routeId?: string; | ||||
|     routeName?: string; | ||||
|   }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Unified ACME configuration options used across proxies and handlers | ||||
|  */ | ||||
| export interface IAcmeOptions { | ||||
|   accountEmail?: string;          // Email for Let's Encrypt account | ||||
|   enabled?: boolean;              // Whether ACME is enabled | ||||
|   port?: number;                  // Port to listen on for ACME challenges (default: 80) | ||||
|   useProduction?: boolean;        // Use production environment (default: staging) | ||||
|   httpsRedirectPort?: number;     // Port to redirect HTTP requests to HTTPS (default: 443) | ||||
|   renewThresholdDays?: number;    // Days before expiry to renew certificates | ||||
|   renewCheckIntervalHours?: number; // How often to check for renewals (in hours) | ||||
|   autoRenew?: boolean;            // Whether to automatically renew certificates | ||||
|   certificateStore?: string;      // Directory to store certificates | ||||
|   skipConfiguredCerts?: boolean;  // Skip domains with existing certificates | ||||
|   routeForwards?: IRouteForwardConfig[]; // Route-specific forwarding configs | ||||
| } | ||||
|  | ||||
| @@ -1,519 +0,0 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js'; | ||||
| import type { ICertificateData, IRouteForwardConfig, IDomainOptions } from '../models/certificate-types.js'; | ||||
| import { Port80HandlerEvents, CertProvisionerEvents } from '../events/certificate-events.js'; | ||||
| import { Port80Handler } from '../../http/port80/port80-handler.js'; | ||||
|  | ||||
| // Interface for NetworkProxyBridge | ||||
| interface INetworkProxyBridge { | ||||
|   applyExternalCertificate(certData: ICertificateData): void; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Type for static certificate provisioning | ||||
|  */ | ||||
| export type TCertProvisionObject = plugins.tsclass.network.ICert | 'http01' | 'dns01'; | ||||
|  | ||||
| /** | ||||
|  * Interface for routes that need certificates | ||||
|  */ | ||||
| interface ICertRoute { | ||||
|   domain: string; | ||||
|   route: IRouteConfig; | ||||
|   tlsMode: 'terminate' | 'terminate-and-reencrypt'; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * CertProvisioner manages certificate provisioning and renewal workflows, | ||||
|  * unifying static certificates and HTTP-01 challenges via Port80Handler. | ||||
|  * | ||||
|  * This class directly works with route configurations instead of converting to domain configs. | ||||
|  */ | ||||
| export class CertProvisioner extends plugins.EventEmitter { | ||||
|   private routeConfigs: IRouteConfig[]; | ||||
|   private certRoutes: ICertRoute[] = []; | ||||
|   private port80Handler: Port80Handler; | ||||
|   private networkProxyBridge: INetworkProxyBridge; | ||||
|   private certProvisionFunction?: (domain: string) => Promise<TCertProvisionObject>; | ||||
|   private routeForwards: IRouteForwardConfig[]; | ||||
|   private renewThresholdDays: number; | ||||
|   private renewCheckIntervalHours: number; | ||||
|   private autoRenew: boolean; | ||||
|   private renewManager?: plugins.taskbuffer.TaskManager; | ||||
|   // Track provisioning type per domain | ||||
|   private provisionMap: Map<string, { type: 'http01' | 'dns01' | 'static', routeRef?: ICertRoute }>; | ||||
|  | ||||
|   /** | ||||
|    * Extract routes that need certificates | ||||
|    * @param routes Route configurations | ||||
|    */ | ||||
|   private extractCertificateRoutesFromRoutes(routes: IRouteConfig[]): ICertRoute[] { | ||||
|     const certRoutes: ICertRoute[] = []; | ||||
|  | ||||
|     // Process all HTTPS routes that need certificates | ||||
|     for (const route of routes) { | ||||
|       // Only process routes with TLS termination that need certificates | ||||
|       if (route.action.type === 'forward' && | ||||
|           route.action.tls && | ||||
|           (route.action.tls.mode === 'terminate' || route.action.tls.mode === 'terminate-and-reencrypt') && | ||||
|           route.match.domains) { | ||||
|  | ||||
|         // Extract domains from the route | ||||
|         const domains = Array.isArray(route.match.domains) | ||||
|           ? route.match.domains | ||||
|           : [route.match.domains]; | ||||
|  | ||||
|         // For each domain in the route, create a certRoute entry | ||||
|         for (const domain of domains) { | ||||
|           // Skip wildcard domains that can't use ACME unless we have a certProvider | ||||
|           if (domain.includes('*') && (!this.certProvisionFunction || this.certProvisionFunction.length === 0)) { | ||||
|             console.warn(`Skipping wildcard domain that requires a certProvisionFunction: ${domain}`); | ||||
|             continue; | ||||
|           } | ||||
|  | ||||
|           certRoutes.push({ | ||||
|             domain, | ||||
|             route, | ||||
|             tlsMode: route.action.tls.mode | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return certRoutes; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Constructor for CertProvisioner | ||||
|    * | ||||
|    * @param routeConfigs Array of route configurations | ||||
|    * @param port80Handler HTTP-01 challenge handler instance | ||||
|    * @param networkProxyBridge Bridge for applying external certificates | ||||
|    * @param certProvider Optional callback returning a static cert or 'http01' | ||||
|    * @param renewThresholdDays Days before expiry to trigger renewals | ||||
|    * @param renewCheckIntervalHours Interval in hours to check for renewals | ||||
|    * @param autoRenew Whether to automatically schedule renewals | ||||
|    * @param routeForwards Route-specific forwarding configs for ACME challenges | ||||
|    */ | ||||
|   constructor( | ||||
|     routeConfigs: IRouteConfig[], | ||||
|     port80Handler: Port80Handler, | ||||
|     networkProxyBridge: INetworkProxyBridge, | ||||
|     certProvider?: (domain: string) => Promise<TCertProvisionObject>, | ||||
|     renewThresholdDays: number = 30, | ||||
|     renewCheckIntervalHours: number = 24, | ||||
|     autoRenew: boolean = true, | ||||
|     routeForwards: IRouteForwardConfig[] = [] | ||||
|   ) { | ||||
|     super(); | ||||
|     this.routeConfigs = routeConfigs; | ||||
|     this.port80Handler = port80Handler; | ||||
|     this.networkProxyBridge = networkProxyBridge; | ||||
|     this.certProvisionFunction = certProvider; | ||||
|     this.renewThresholdDays = renewThresholdDays; | ||||
|     this.renewCheckIntervalHours = renewCheckIntervalHours; | ||||
|     this.autoRenew = autoRenew; | ||||
|     this.provisionMap = new Map(); | ||||
|     this.routeForwards = routeForwards; | ||||
|  | ||||
|     // Extract certificate routes during instantiation | ||||
|     this.certRoutes = this.extractCertificateRoutesFromRoutes(routeConfigs); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Start initial provisioning and schedule renewals. | ||||
|    */ | ||||
|   public async start(): Promise<void> { | ||||
|     // Subscribe to Port80Handler certificate events | ||||
|     this.setupEventSubscriptions(); | ||||
|  | ||||
|     // Apply route forwarding for ACME challenges | ||||
|     this.setupForwardingConfigs(); | ||||
|  | ||||
|     // Initial provisioning for all domains in routes | ||||
|     await this.provisionAllCertificates(); | ||||
|  | ||||
|     // Schedule renewals if enabled | ||||
|     if (this.autoRenew) { | ||||
|       this.scheduleRenewals(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Set up event subscriptions for certificate events | ||||
|    */ | ||||
|   private setupEventSubscriptions(): void { | ||||
|     this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, (data: ICertificateData) => { | ||||
|       // Add route reference if we have it | ||||
|       const routeRef = this.findRouteForDomain(data.domain); | ||||
|       const enhancedData: ICertificateData = { | ||||
|         ...data, | ||||
|         source: 'http01', | ||||
|         isRenewal: false, | ||||
|         routeReference: routeRef ? { | ||||
|           routeId: routeRef.route.name, | ||||
|           routeName: routeRef.route.name | ||||
|         } : undefined | ||||
|       }; | ||||
|  | ||||
|       this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, enhancedData); | ||||
|     }); | ||||
|  | ||||
|     this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, (data: ICertificateData) => { | ||||
|       // Add route reference if we have it | ||||
|       const routeRef = this.findRouteForDomain(data.domain); | ||||
|       const enhancedData: ICertificateData = { | ||||
|         ...data, | ||||
|         source: 'http01', | ||||
|         isRenewal: true, | ||||
|         routeReference: routeRef ? { | ||||
|           routeId: routeRef.route.name, | ||||
|           routeName: routeRef.route.name | ||||
|         } : undefined | ||||
|       }; | ||||
|  | ||||
|       this.emit(CertProvisionerEvents.CERTIFICATE_RENEWED, enhancedData); | ||||
|     }); | ||||
|  | ||||
|     this.port80Handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, (error) => { | ||||
|       this.emit(CertProvisionerEvents.CERTIFICATE_FAILED, error); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Find a route for a given domain | ||||
|    */ | ||||
|   private findRouteForDomain(domain: string): ICertRoute | undefined { | ||||
|     return this.certRoutes.find(certRoute => certRoute.domain === domain); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Set up forwarding configurations for the Port80Handler | ||||
|    */ | ||||
|   private setupForwardingConfigs(): void { | ||||
|     for (const config of this.routeForwards) { | ||||
|       const domainOptions: IDomainOptions = { | ||||
|         domainName: config.domain, | ||||
|         sslRedirect: config.sslRedirect || false, | ||||
|         acmeMaintenance: false, | ||||
|         forward: config.target ? { | ||||
|           ip: config.target.host, | ||||
|           port: config.target.port | ||||
|         } : undefined | ||||
|       }; | ||||
|       this.port80Handler.addDomain(domainOptions); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Provision certificates for all routes that need them | ||||
|    */ | ||||
|   private async provisionAllCertificates(): Promise<void> { | ||||
|     for (const certRoute of this.certRoutes) { | ||||
|       await this.provisionCertificateForRoute(certRoute); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Provision a certificate for a route | ||||
|    */ | ||||
|   private async provisionCertificateForRoute(certRoute: ICertRoute): Promise<void> { | ||||
|     const { domain, route } = certRoute; | ||||
|     const isWildcard = domain.includes('*'); | ||||
|     let provision: TCertProvisionObject = 'http01'; | ||||
|  | ||||
|     // Try to get a certificate from the provision function | ||||
|     if (this.certProvisionFunction) { | ||||
|       try { | ||||
|         provision = await this.certProvisionFunction(domain); | ||||
|       } catch (err) { | ||||
|         console.error(`certProvider error for ${domain} on route ${route.name || 'unnamed'}:`, err); | ||||
|       } | ||||
|     } else if (isWildcard) { | ||||
|       // No certProvider: cannot handle wildcard without DNS-01 support | ||||
|       console.warn(`Skipping wildcard domain without certProvisionFunction: ${domain}`); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Store the route reference with the provision type | ||||
|     this.provisionMap.set(domain, { | ||||
|       type: provision === 'http01' || provision === 'dns01' ? provision : 'static', | ||||
|       routeRef: certRoute | ||||
|     }); | ||||
|  | ||||
|     // Handle different provisioning methods | ||||
|     if (provision === 'http01') { | ||||
|       if (isWildcard) { | ||||
|         console.warn(`Skipping HTTP-01 for wildcard domain: ${domain}`); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       this.port80Handler.addDomain({ | ||||
|         domainName: domain, | ||||
|         sslRedirect: true, | ||||
|         acmeMaintenance: true, | ||||
|         routeReference: { | ||||
|           routeId: route.name || domain, | ||||
|           routeName: route.name | ||||
|         } | ||||
|       }); | ||||
|     } else if (provision === 'dns01') { | ||||
|       // DNS-01 challenges would be handled by the certProvisionFunction | ||||
|       // DNS-01 handling would go here if implemented | ||||
|       console.log(`DNS-01 challenge type set for ${domain}`); | ||||
|     } else { | ||||
|       // Static certificate (e.g., DNS-01 provisioned or user-provided) | ||||
|       const certObj = provision as plugins.tsclass.network.ICert; | ||||
|       const certData: ICertificateData = { | ||||
|         domain: certObj.domainName, | ||||
|         certificate: certObj.publicKey, | ||||
|         privateKey: certObj.privateKey, | ||||
|         expiryDate: new Date(certObj.validUntil), | ||||
|         source: 'static', | ||||
|         isRenewal: false, | ||||
|         routeReference: { | ||||
|           routeId: route.name || domain, | ||||
|           routeName: route.name | ||||
|         } | ||||
|       }; | ||||
|  | ||||
|       this.networkProxyBridge.applyExternalCertificate(certData); | ||||
|       this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, certData); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Schedule certificate renewals using a task manager | ||||
|    */ | ||||
|   private scheduleRenewals(): void { | ||||
|     this.renewManager = new plugins.taskbuffer.TaskManager(); | ||||
|  | ||||
|     const renewTask = new plugins.taskbuffer.Task({ | ||||
|       name: 'CertificateRenewals', | ||||
|       taskFunction: async () => await this.performRenewals() | ||||
|     }); | ||||
|  | ||||
|     const hours = this.renewCheckIntervalHours; | ||||
|     const cronExpr = `0 0 */${hours} * * *`; | ||||
|  | ||||
|     this.renewManager.addAndScheduleTask(renewTask, cronExpr); | ||||
|     this.renewManager.start(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Perform renewals for all domains that need it | ||||
|    */ | ||||
|   private async performRenewals(): Promise<void> { | ||||
|     for (const [domain, info] of this.provisionMap.entries()) { | ||||
|       // Skip wildcard domains for HTTP-01 challenges | ||||
|       if (domain.includes('*') && info.type === 'http01') continue; | ||||
|  | ||||
|       try { | ||||
|         await this.renewCertificateForDomain(domain, info.type, info.routeRef); | ||||
|       } catch (err) { | ||||
|         console.error(`Renewal error for ${domain}:`, err); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Renew a certificate for a specific domain | ||||
|    * @param domain Domain to renew | ||||
|    * @param provisionType Type of provisioning for this domain | ||||
|    * @param certRoute The route reference for this domain | ||||
|    */ | ||||
|   private async renewCertificateForDomain( | ||||
|     domain: string, | ||||
|     provisionType: 'http01' | 'dns01' | 'static', | ||||
|     certRoute?: ICertRoute | ||||
|   ): Promise<void> { | ||||
|     if (provisionType === 'http01') { | ||||
|       await this.port80Handler.renewCertificate(domain); | ||||
|     } else if ((provisionType === 'static' || provisionType === 'dns01') && this.certProvisionFunction) { | ||||
|       const provision = await this.certProvisionFunction(domain); | ||||
|  | ||||
|       if (provision !== 'http01' && provision !== 'dns01') { | ||||
|         const certObj = provision as plugins.tsclass.network.ICert; | ||||
|         const routeRef = certRoute?.route; | ||||
|  | ||||
|         const certData: ICertificateData = { | ||||
|           domain: certObj.domainName, | ||||
|           certificate: certObj.publicKey, | ||||
|           privateKey: certObj.privateKey, | ||||
|           expiryDate: new Date(certObj.validUntil), | ||||
|           source: 'static', | ||||
|           isRenewal: true, | ||||
|           routeReference: routeRef ? { | ||||
|             routeId: routeRef.name || domain, | ||||
|             routeName: routeRef.name | ||||
|           } : undefined | ||||
|         }; | ||||
|  | ||||
|         this.networkProxyBridge.applyExternalCertificate(certData); | ||||
|         this.emit(CertProvisionerEvents.CERTIFICATE_RENEWED, certData); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Stop all scheduled renewal tasks. | ||||
|    */ | ||||
|   public async stop(): Promise<void> { | ||||
|     if (this.renewManager) { | ||||
|       this.renewManager.stop(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Request a certificate on-demand for the given domain. | ||||
|    * This will look for a matching route configuration and provision accordingly. | ||||
|    * | ||||
|    * @param domain Domain name to provision | ||||
|    */ | ||||
|   public async requestCertificate(domain: string): Promise<void> { | ||||
|     const isWildcard = domain.includes('*'); | ||||
|     // Find matching route | ||||
|     const certRoute = this.findRouteForDomain(domain); | ||||
|  | ||||
|     // Determine provisioning method | ||||
|     let provision: TCertProvisionObject = 'http01'; | ||||
|  | ||||
|     if (this.certProvisionFunction) { | ||||
|       provision = await this.certProvisionFunction(domain); | ||||
|     } else if (isWildcard) { | ||||
|       // Cannot perform HTTP-01 on wildcard without certProvider | ||||
|       throw new Error(`Cannot request certificate for wildcard domain without certProvisionFunction: ${domain}`); | ||||
|     } | ||||
|  | ||||
|     if (provision === 'http01') { | ||||
|       if (isWildcard) { | ||||
|         throw new Error(`Cannot request HTTP-01 certificate for wildcard domain: ${domain}`); | ||||
|       } | ||||
|       await this.port80Handler.renewCertificate(domain); | ||||
|     } else if (provision === 'dns01') { | ||||
|       // DNS-01 challenges would be handled by external mechanisms | ||||
|       console.log(`DNS-01 challenge requested for ${domain}`); | ||||
|     } else { | ||||
|       // Static certificate (e.g., DNS-01 provisioned) supports wildcards | ||||
|       const certObj = provision as plugins.tsclass.network.ICert; | ||||
|       const certData: ICertificateData = { | ||||
|         domain: certObj.domainName, | ||||
|         certificate: certObj.publicKey, | ||||
|         privateKey: certObj.privateKey, | ||||
|         expiryDate: new Date(certObj.validUntil), | ||||
|         source: 'static', | ||||
|         isRenewal: false, | ||||
|         routeReference: certRoute ? { | ||||
|           routeId: certRoute.route.name || domain, | ||||
|           routeName: certRoute.route.name | ||||
|         } : undefined | ||||
|       }; | ||||
|  | ||||
|       this.networkProxyBridge.applyExternalCertificate(certData); | ||||
|       this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, certData); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Add a new domain for certificate provisioning | ||||
|    * | ||||
|    * @param domain Domain to add | ||||
|    * @param options Domain configuration options | ||||
|    */ | ||||
|   public async addDomain(domain: string, options?: { | ||||
|     sslRedirect?: boolean; | ||||
|     acmeMaintenance?: boolean; | ||||
|     routeId?: string; | ||||
|     routeName?: string; | ||||
|   }): Promise<void> { | ||||
|     const domainOptions: IDomainOptions = { | ||||
|       domainName: domain, | ||||
|       sslRedirect: options?.sslRedirect ?? true, | ||||
|       acmeMaintenance: options?.acmeMaintenance ?? true, | ||||
|       routeReference: { | ||||
|         routeId: options?.routeId, | ||||
|         routeName: options?.routeName | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     this.port80Handler.addDomain(domainOptions); | ||||
|  | ||||
|     // Find matching route or create a generic one | ||||
|     const existingRoute = this.findRouteForDomain(domain); | ||||
|     if (existingRoute) { | ||||
|       await this.provisionCertificateForRoute(existingRoute); | ||||
|     } else { | ||||
|       // We don't have a route, just provision the domain | ||||
|       const isWildcard = domain.includes('*'); | ||||
|       let provision: TCertProvisionObject = 'http01'; | ||||
|  | ||||
|       if (this.certProvisionFunction) { | ||||
|         provision = await this.certProvisionFunction(domain); | ||||
|       } else if (isWildcard) { | ||||
|         throw new Error(`Cannot request certificate for wildcard domain without certProvisionFunction: ${domain}`); | ||||
|       } | ||||
|  | ||||
|       this.provisionMap.set(domain, { | ||||
|         type: provision === 'http01' || provision === 'dns01' ? provision : 'static' | ||||
|       }); | ||||
|  | ||||
|       if (provision !== 'http01' && provision !== 'dns01') { | ||||
|         const certObj = provision as plugins.tsclass.network.ICert; | ||||
|         const certData: ICertificateData = { | ||||
|           domain: certObj.domainName, | ||||
|           certificate: certObj.publicKey, | ||||
|           privateKey: certObj.privateKey, | ||||
|           expiryDate: new Date(certObj.validUntil), | ||||
|           source: 'static', | ||||
|           isRenewal: false, | ||||
|           routeReference: { | ||||
|             routeId: options?.routeId, | ||||
|             routeName: options?.routeName | ||||
|           } | ||||
|         }; | ||||
|  | ||||
|         this.networkProxyBridge.applyExternalCertificate(certData); | ||||
|         this.emit(CertProvisionerEvents.CERTIFICATE_ISSUED, certData); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Update routes with new configurations | ||||
|    * This replaces all existing routes with new ones and re-provisions certificates as needed | ||||
|    * | ||||
|    * @param newRoutes New route configurations to use | ||||
|    */ | ||||
|   public async updateRoutes(newRoutes: IRouteConfig[]): Promise<void> { | ||||
|     // Store the new route configs | ||||
|     this.routeConfigs = newRoutes; | ||||
|  | ||||
|     // Extract new certificate routes | ||||
|     const newCertRoutes = this.extractCertificateRoutesFromRoutes(newRoutes); | ||||
|  | ||||
|     // Find domains that no longer need certificates | ||||
|     const oldDomains = new Set(this.certRoutes.map(r => r.domain)); | ||||
|     const newDomains = new Set(newCertRoutes.map(r => r.domain)); | ||||
|  | ||||
|     // Domains to remove | ||||
|     const domainsToRemove = [...oldDomains].filter(d => !newDomains.has(d)); | ||||
|  | ||||
|     // Remove obsolete domains from provision map | ||||
|     for (const domain of domainsToRemove) { | ||||
|       this.provisionMap.delete(domain); | ||||
|     } | ||||
|  | ||||
|     // Update the cert routes | ||||
|     this.certRoutes = newCertRoutes; | ||||
|  | ||||
|     // Provision certificates for new routes | ||||
|     for (const certRoute of newCertRoutes) { | ||||
|       if (!oldDomains.has(certRoute.domain)) { | ||||
|         await this.provisionCertificateForRoute(certRoute); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Type alias for backward compatibility | ||||
| export type TSmartProxyCertProvisionObject = TCertProvisionObject; | ||||
| @@ -1,3 +0,0 @@ | ||||
| /** | ||||
|  * Certificate providers | ||||
|  */ | ||||
| @@ -1,234 +0,0 @@ | ||||
| import * as fs from 'fs'; | ||||
| import * as path from 'path'; | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import type { ICertificateData, ICertificates } from '../models/certificate-types.js'; | ||||
| import { ensureCertificateDirectory } from '../utils/certificate-helpers.js'; | ||||
|  | ||||
| /** | ||||
|  * FileStorage provides file system storage for certificates | ||||
|  */ | ||||
| export class FileStorage { | ||||
|   private storageDir: string; | ||||
|    | ||||
|   /** | ||||
|    * Creates a new file storage provider | ||||
|    * @param storageDir Directory to store certificates | ||||
|    */ | ||||
|   constructor(storageDir: string) { | ||||
|     this.storageDir = path.resolve(storageDir); | ||||
|     ensureCertificateDirectory(this.storageDir); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Save a certificate to the file system | ||||
|    * @param domain Domain name | ||||
|    * @param certData Certificate data to save | ||||
|    */ | ||||
|   public async saveCertificate(domain: string, certData: ICertificateData): Promise<void> { | ||||
|     const sanitizedDomain = this.sanitizeDomain(domain); | ||||
|     const certDir = path.join(this.storageDir, sanitizedDomain); | ||||
|     ensureCertificateDirectory(certDir); | ||||
|      | ||||
|     const certPath = path.join(certDir, 'fullchain.pem'); | ||||
|     const keyPath = path.join(certDir, 'privkey.pem'); | ||||
|     const metaPath = path.join(certDir, 'metadata.json'); | ||||
|      | ||||
|     // Write certificate and private key | ||||
|     await fs.promises.writeFile(certPath, certData.certificate, 'utf8'); | ||||
|     await fs.promises.writeFile(keyPath, certData.privateKey, 'utf8'); | ||||
|      | ||||
|     // Write metadata | ||||
|     const metadata = { | ||||
|       domain: certData.domain, | ||||
|       expiryDate: certData.expiryDate.toISOString(), | ||||
|       source: certData.source || 'unknown', | ||||
|       issuedAt: new Date().toISOString() | ||||
|     }; | ||||
|      | ||||
|     await fs.promises.writeFile( | ||||
|       metaPath,  | ||||
|       JSON.stringify(metadata, null, 2),  | ||||
|       'utf8' | ||||
|     ); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Load a certificate from the file system | ||||
|    * @param domain Domain name | ||||
|    * @returns Certificate data if found, null otherwise | ||||
|    */ | ||||
|   public async loadCertificate(domain: string): Promise<ICertificateData | null> { | ||||
|     const sanitizedDomain = this.sanitizeDomain(domain); | ||||
|     const certDir = path.join(this.storageDir, sanitizedDomain); | ||||
|      | ||||
|     if (!fs.existsSync(certDir)) { | ||||
|       return null; | ||||
|     } | ||||
|      | ||||
|     const certPath = path.join(certDir, 'fullchain.pem'); | ||||
|     const keyPath = path.join(certDir, 'privkey.pem'); | ||||
|     const metaPath = path.join(certDir, 'metadata.json'); | ||||
|      | ||||
|     try { | ||||
|       // Check if all required files exist | ||||
|       if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) { | ||||
|         return null; | ||||
|       } | ||||
|        | ||||
|       // Read certificate and private key | ||||
|       const certificate = await fs.promises.readFile(certPath, 'utf8'); | ||||
|       const privateKey = await fs.promises.readFile(keyPath, 'utf8'); | ||||
|        | ||||
|       // Try to read metadata if available | ||||
|       let expiryDate = new Date(); | ||||
|       let source: 'static' | 'http01' | 'dns01' | undefined; | ||||
|        | ||||
|       if (fs.existsSync(metaPath)) { | ||||
|         const metaContent = await fs.promises.readFile(metaPath, 'utf8'); | ||||
|         const metadata = JSON.parse(metaContent); | ||||
|          | ||||
|         if (metadata.expiryDate) { | ||||
|           expiryDate = new Date(metadata.expiryDate); | ||||
|         } | ||||
|          | ||||
|         if (metadata.source) { | ||||
|           source = metadata.source as 'static' | 'http01' | 'dns01'; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       return { | ||||
|         domain, | ||||
|         certificate, | ||||
|         privateKey, | ||||
|         expiryDate, | ||||
|         source | ||||
|       }; | ||||
|     } catch (error) { | ||||
|       console.error(`Error loading certificate for ${domain}:`, error); | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Delete a certificate from the file system | ||||
|    * @param domain Domain name | ||||
|    */ | ||||
|   public async deleteCertificate(domain: string): Promise<boolean> { | ||||
|     const sanitizedDomain = this.sanitizeDomain(domain); | ||||
|     const certDir = path.join(this.storageDir, sanitizedDomain); | ||||
|      | ||||
|     if (!fs.existsSync(certDir)) { | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       // Recursively delete the certificate directory | ||||
|       await this.deleteDirectory(certDir); | ||||
|       return true; | ||||
|     } catch (error) { | ||||
|       console.error(`Error deleting certificate for ${domain}:`, error); | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * List all domains with stored certificates | ||||
|    * @returns Array of domain names | ||||
|    */ | ||||
|   public async listCertificates(): Promise<string[]> { | ||||
|     try { | ||||
|       const entries = await fs.promises.readdir(this.storageDir, { withFileTypes: true }); | ||||
|       return entries | ||||
|         .filter(entry => entry.isDirectory()) | ||||
|         .map(entry => entry.name); | ||||
|     } catch (error) { | ||||
|       console.error('Error listing certificates:', error); | ||||
|       return []; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Check if a certificate is expiring soon | ||||
|    * @param domain Domain name | ||||
|    * @param thresholdDays Days threshold to consider expiring | ||||
|    * @returns Information about expiring certificate or null | ||||
|    */ | ||||
|   public async isExpiringSoon( | ||||
|     domain: string,  | ||||
|     thresholdDays: number = 30 | ||||
|   ): Promise<{ domain: string; expiryDate: Date; daysRemaining: number } | null> { | ||||
|     const certData = await this.loadCertificate(domain); | ||||
|      | ||||
|     if (!certData) { | ||||
|       return null; | ||||
|     } | ||||
|      | ||||
|     const now = new Date(); | ||||
|     const expiryDate = certData.expiryDate; | ||||
|     const timeRemaining = expiryDate.getTime() - now.getTime(); | ||||
|     const daysRemaining = Math.floor(timeRemaining / (1000 * 60 * 60 * 24)); | ||||
|      | ||||
|     if (daysRemaining <= thresholdDays) { | ||||
|       return { | ||||
|         domain, | ||||
|         expiryDate, | ||||
|         daysRemaining | ||||
|       }; | ||||
|     } | ||||
|      | ||||
|     return null; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Check all certificates for expiration | ||||
|    * @param thresholdDays Days threshold to consider expiring | ||||
|    * @returns List of expiring certificates | ||||
|    */ | ||||
|   public async getExpiringCertificates( | ||||
|     thresholdDays: number = 30 | ||||
|   ): Promise<Array<{ domain: string; expiryDate: Date; daysRemaining: number }>> { | ||||
|     const domains = await this.listCertificates(); | ||||
|     const expiringCerts = []; | ||||
|      | ||||
|     for (const domain of domains) { | ||||
|       const expiring = await this.isExpiringSoon(domain, thresholdDays); | ||||
|       if (expiring) { | ||||
|         expiringCerts.push(expiring); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     return expiringCerts; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Delete a directory recursively | ||||
|    * @param directoryPath Directory to delete | ||||
|    */ | ||||
|   private async deleteDirectory(directoryPath: string): Promise<void> { | ||||
|     if (fs.existsSync(directoryPath)) { | ||||
|       const entries = await fs.promises.readdir(directoryPath, { withFileTypes: true }); | ||||
|        | ||||
|       for (const entry of entries) { | ||||
|         const fullPath = path.join(directoryPath, entry.name); | ||||
|          | ||||
|         if (entry.isDirectory()) { | ||||
|           await this.deleteDirectory(fullPath); | ||||
|         } else { | ||||
|           await fs.promises.unlink(fullPath); | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       await fs.promises.rmdir(directoryPath); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Sanitize a domain name for use as a directory name | ||||
|    * @param domain Domain name | ||||
|    * @returns Sanitized domain name | ||||
|    */ | ||||
|   private sanitizeDomain(domain: string): string { | ||||
|     // Replace wildcard and any invalid filesystem characters | ||||
|     return domain.replace(/\*/g, '_wildcard_').replace(/[/\\:*?"<>|]/g, '_'); | ||||
|   } | ||||
| } | ||||
| @@ -1,3 +0,0 @@ | ||||
| /** | ||||
|  * Certificate storage mechanisms | ||||
|  */ | ||||
| @@ -1,50 +0,0 @@ | ||||
| import * as fs from 'fs'; | ||||
| import * as path from 'path'; | ||||
| import { fileURLToPath } from 'url'; | ||||
| import type { ICertificates } from '../models/certificate-types.js'; | ||||
|  | ||||
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||||
|  | ||||
| /** | ||||
|  * Loads the default SSL certificates from the assets directory | ||||
|  * @returns The certificate key pair | ||||
|  */ | ||||
| export function loadDefaultCertificates(): ICertificates { | ||||
|   try { | ||||
|     // Need to adjust path from /ts/certificate/utils to /assets/certs | ||||
|     const certPath = path.join(__dirname, '..', '..', '..', 'assets', 'certs'); | ||||
|     const privateKey = fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'); | ||||
|     const publicKey = fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8'); | ||||
|  | ||||
|     if (!privateKey || !publicKey) { | ||||
|       throw new Error('Failed to load default certificates'); | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|       privateKey, | ||||
|       publicKey | ||||
|     }; | ||||
|   } catch (error) { | ||||
|     console.error('Error loading default certificates:', error); | ||||
|     throw error; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Checks if a certificate file exists at the specified path | ||||
|  * @param certPath Path to check for certificate | ||||
|  * @returns True if the certificate exists, false otherwise | ||||
|  */ | ||||
| export function certificateExists(certPath: string): boolean { | ||||
|   return fs.existsSync(certPath); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Ensures the certificate directory exists | ||||
|  * @param dirPath Path to the certificate directory | ||||
|  */ | ||||
| export function ensureCertificateDirectory(dirPath: string): void { | ||||
|   if (!fs.existsSync(dirPath)) { | ||||
|     fs.mkdirSync(dirPath, { recursive: true }); | ||||
|   } | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
| import type { Port80Handler } from '../http/port80/port80-handler.js'; | ||||
| // Port80Handler removed - use SmartCertManager instead | ||||
| import { Port80HandlerEvents } from './types.js'; | ||||
| import type { ICertificateData, ICertificateFailure, ICertificateExpiring } from './types.js'; | ||||
|  | ||||
| @@ -16,7 +16,7 @@ export interface Port80HandlerSubscribers { | ||||
|  * Subscribes to Port80Handler events based on provided callbacks | ||||
|  */ | ||||
| export function subscribeToPort80Handler( | ||||
|   handler: Port80Handler, | ||||
|   handler: any, | ||||
|   subscribers: Port80HandlerSubscribers | ||||
| ): void { | ||||
|   if (subscribers.onCertificateIssued) { | ||||
|   | ||||
| @@ -34,7 +34,7 @@ export interface ICertificateData { | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Events emitted by the Port80Handler | ||||
|  * @deprecated Events emitted by the Port80Handler - use SmartCertManager instead | ||||
|  */ | ||||
| export enum Port80HandlerEvents { | ||||
|   CERTIFICATE_ISSUED = 'certificate-issued', | ||||
|   | ||||
| @@ -1,34 +1,25 @@ | ||||
| import type { Port80Handler } from '../../http/port80/port80-handler.js'; | ||||
| // Port80Handler has been removed - use SmartCertManager instead | ||||
| import { Port80HandlerEvents } from '../models/common-types.js'; | ||||
| import type { ICertificateData, ICertificateFailure, ICertificateExpiring } from '../models/common-types.js'; | ||||
|  | ||||
| // Re-export for backward compatibility | ||||
| export { Port80HandlerEvents }; | ||||
|  | ||||
| /** | ||||
|  * Subscribers callback definitions for Port80Handler events | ||||
|  * @deprecated Use SmartCertManager instead | ||||
|  */ | ||||
| export interface IPort80HandlerSubscribers { | ||||
|   onCertificateIssued?: (data: ICertificateData) => void; | ||||
|   onCertificateRenewed?: (data: ICertificateData) => void; | ||||
|   onCertificateFailed?: (data: ICertificateFailure) => void; | ||||
|   onCertificateExpiring?: (data: ICertificateExpiring) => void; | ||||
|   onCertificateIssued?: (data: any) => void; | ||||
|   onCertificateRenewed?: (data: any) => void; | ||||
|   onCertificateFailed?: (data: any) => void; | ||||
|   onCertificateExpiring?: (data: any) => void; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Subscribes to Port80Handler events based on provided callbacks | ||||
|  * @deprecated Use SmartCertManager instead | ||||
|  */ | ||||
| export function subscribeToPort80Handler( | ||||
|   handler: Port80Handler, | ||||
|   handler: any, | ||||
|   subscribers: IPort80HandlerSubscribers | ||||
| ): void { | ||||
|   if (subscribers.onCertificateIssued) { | ||||
|     handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, subscribers.onCertificateIssued); | ||||
|   } | ||||
|   if (subscribers.onCertificateRenewed) { | ||||
|     handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, subscribers.onCertificateRenewed); | ||||
|   } | ||||
|   if (subscribers.onCertificateFailed) { | ||||
|     handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, subscribers.onCertificateFailed); | ||||
|   } | ||||
|   if (subscribers.onCertificateExpiring) { | ||||
|     handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, subscribers.onCertificateExpiring); | ||||
|   } | ||||
|   console.warn('subscribeToPort80Handler is deprecated - use SmartCertManager instead'); | ||||
| } | ||||
| @@ -1,8 +1,12 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import type { | ||||
|   IDomainOptions, | ||||
|   IAcmeOptions | ||||
| } from '../../certificate/models/certificate-types.js'; | ||||
| // Certificate types have been removed - use SmartCertManager instead | ||||
| export interface IDomainOptions { | ||||
|   domainName: string; | ||||
|   sslRedirect: boolean; | ||||
|   acmeMaintenance: boolean; | ||||
|   forward?: { ip: string; port: number }; | ||||
|   acmeForward?: { ip: string; port: number }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * HTTP-specific event types | ||||
|   | ||||
| @@ -1,169 +0,0 @@ | ||||
| /** | ||||
|  * Type definitions for SmartAcme interfaces used by ChallengeResponder | ||||
|  * These reflect the actual SmartAcme API based on the documentation | ||||
|  * | ||||
|  * Also includes route-based interfaces for Port80Handler to extract domains | ||||
|  * that need certificate management from route configurations. | ||||
|  */ | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js'; | ||||
|  | ||||
| /** | ||||
|  * Structure for SmartAcme certificate result | ||||
|  */ | ||||
| export interface ISmartAcmeCert { | ||||
|   id?: string; | ||||
|   domainName: string; | ||||
|   created?: number | Date | string; | ||||
|   privateKey: string; | ||||
|   publicKey: string; | ||||
|   csr?: string; | ||||
|   validUntil: number | Date | string; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Structure for SmartAcme options | ||||
|  */ | ||||
| export interface ISmartAcmeOptions { | ||||
|   accountEmail: string; | ||||
|   certManager: ICertManager; | ||||
|   environment: 'production' | 'integration'; | ||||
|   challengeHandlers: IChallengeHandler<any>[]; | ||||
|   challengePriority?: string[]; | ||||
|   retryOptions?: { | ||||
|     retries?: number; | ||||
|     factor?: number; | ||||
|     minTimeoutMs?: number; | ||||
|     maxTimeoutMs?: number; | ||||
|   }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Interface for certificate manager | ||||
|  */ | ||||
| export interface ICertManager { | ||||
|   init(): Promise<void>; | ||||
|   get(domainName: string): Promise<ISmartAcmeCert | null>; | ||||
|   put(cert: ISmartAcmeCert): Promise<ISmartAcmeCert>; | ||||
|   delete(domainName: string): Promise<void>; | ||||
|   close?(): Promise<void>; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Interface for challenge handler | ||||
|  */ | ||||
| export interface IChallengeHandler<T> { | ||||
|   getSupportedTypes(): string[]; | ||||
|   prepare(ch: T): Promise<void>; | ||||
|   verify?(ch: T): Promise<void>; | ||||
|   cleanup(ch: T): Promise<void>; | ||||
|   checkWetherDomainIsSupported(domain: string): Promise<boolean>; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * HTTP-01 challenge type | ||||
|  */ | ||||
| export interface IHttp01Challenge { | ||||
|   type: string; // 'http-01' | ||||
|   token: string; | ||||
|   keyAuthorization: string; | ||||
|   webPath: string; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * HTTP-01 Memory Handler Interface | ||||
|  */ | ||||
| export interface IHttp01MemoryHandler extends IChallengeHandler<IHttp01Challenge> { | ||||
|   handleRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse, next?: () => void): void; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * SmartAcme main class interface | ||||
|  */ | ||||
| export interface ISmartAcme { | ||||
|   start(): Promise<void>; | ||||
|   stop(): Promise<void>; | ||||
|   getCertificateForDomain(domain: string): Promise<ISmartAcmeCert>; | ||||
|   on?(event: string, listener: (data: any) => void): void; | ||||
|   eventEmitter?: plugins.EventEmitter; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Port80Handler route options | ||||
|  */ | ||||
| export interface IPort80RouteOptions { | ||||
|   // The domain for the certificate | ||||
|   domain: string; | ||||
|  | ||||
|   // Whether to redirect HTTP to HTTPS | ||||
|   sslRedirect: boolean; | ||||
|  | ||||
|   // Whether to enable ACME certificate management | ||||
|   acmeMaintenance: boolean; | ||||
|  | ||||
|   // Optional target for forwarding HTTP requests | ||||
|   forward?: { | ||||
|     ip: string; | ||||
|     port: number; | ||||
|   }; | ||||
|  | ||||
|   // Optional target for forwarding ACME challenge requests | ||||
|   acmeForward?: { | ||||
|     ip: string; | ||||
|     port: number; | ||||
|   }; | ||||
|  | ||||
|   // Reference to the route that requested this certificate | ||||
|   routeReference?: { | ||||
|     routeId?: string; | ||||
|     routeName?: string; | ||||
|   }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Extract domains that need certificate management from routes | ||||
|  * @param routes Route configurations to extract domains from | ||||
|  * @returns Array of Port80RouteOptions for each domain | ||||
|  */ | ||||
| export function extractPort80RoutesFromRoutes(routes: IRouteConfig[]): IPort80RouteOptions[] { | ||||
|   const result: IPort80RouteOptions[] = []; | ||||
|  | ||||
|   for (const route of routes) { | ||||
|     // Skip routes that don't have domains or TLS configuration | ||||
|     if (!route.match.domains || !route.action.tls) continue; | ||||
|  | ||||
|     // Skip routes that don't terminate TLS | ||||
|     if (route.action.tls.mode !== 'terminate' && route.action.tls.mode !== 'terminate-and-reencrypt') continue; | ||||
|  | ||||
|     // Only routes with automatic certificates need ACME | ||||
|     if (route.action.tls.certificate !== 'auto') continue; | ||||
|  | ||||
|     // Get domains from route | ||||
|     const domains = Array.isArray(route.match.domains) | ||||
|       ? route.match.domains | ||||
|       : [route.match.domains]; | ||||
|  | ||||
|     // Create Port80RouteOptions for each domain | ||||
|     for (const domain of domains) { | ||||
|       // Skip wildcards (we can't get certificates for them) | ||||
|       if (domain.includes('*')) continue; | ||||
|  | ||||
|       // Create Port80RouteOptions | ||||
|       const options: IPort80RouteOptions = { | ||||
|         domain, | ||||
|         sslRedirect: true, // Default to true for HTTPS routes | ||||
|         acmeMaintenance: true, // Default to true for auto certificates | ||||
|  | ||||
|         // Add route reference | ||||
|         routeReference: { | ||||
|           routeName: route.name | ||||
|         } | ||||
|       }; | ||||
|  | ||||
|       // Add domain to result | ||||
|       result.push(options); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return result; | ||||
| } | ||||
| @@ -1,246 +0,0 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import { IncomingMessage, ServerResponse } from 'http'; | ||||
| import { | ||||
|   CertificateEvents | ||||
| } from '../../certificate/events/certificate-events.js'; | ||||
| import type { | ||||
|   ICertificateData, | ||||
|   ICertificateFailure, | ||||
|   ICertificateExpiring | ||||
| } from '../../certificate/models/certificate-types.js'; | ||||
| import type { | ||||
|   ISmartAcme, | ||||
|   ISmartAcmeCert, | ||||
|   ISmartAcmeOptions, | ||||
|   IHttp01MemoryHandler | ||||
| } from './acme-interfaces.js'; | ||||
|  | ||||
| /** | ||||
|  * ChallengeResponder handles ACME HTTP-01 challenges by leveraging SmartAcme | ||||
|  * It acts as a bridge between the HTTP server and the ACME challenge verification process | ||||
|  */ | ||||
| export class ChallengeResponder extends plugins.EventEmitter { | ||||
|   private smartAcme: ISmartAcme | null = null; | ||||
|   private http01Handler: IHttp01MemoryHandler | null = null; | ||||
|  | ||||
|   /** | ||||
|    * Creates a new challenge responder | ||||
|    * @param useProduction Whether to use production ACME servers | ||||
|    * @param email Account email for ACME | ||||
|    * @param certificateStore Directory to store certificates | ||||
|    */ | ||||
|   constructor( | ||||
|     private readonly useProduction: boolean = false, | ||||
|     private readonly email: string = 'admin@example.com', | ||||
|     private readonly certificateStore: string = './certs' | ||||
|   ) { | ||||
|     super(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Initialize the ACME client | ||||
|    */ | ||||
|   public async initialize(): Promise<void> { | ||||
|     try { | ||||
|       // Create the HTTP-01 memory handler from SmartACME | ||||
|       this.http01Handler = new plugins.smartacme.handlers.Http01MemoryHandler(); | ||||
|  | ||||
|       // Ensure certificate store directory exists | ||||
|       await this.ensureCertificateStore(); | ||||
|  | ||||
|       // Create a MemoryCertManager for certificate storage | ||||
|       const certManager = new plugins.smartacme.certmanagers.MemoryCertManager(); | ||||
|  | ||||
|       // Initialize the SmartACME client with appropriate options | ||||
|       this.smartAcme = new plugins.smartacme.SmartAcme({ | ||||
|         accountEmail: this.email, | ||||
|         certManager: certManager, | ||||
|         environment: this.useProduction ? 'production' : 'integration', | ||||
|         challengeHandlers: [this.http01Handler], | ||||
|         challengePriority: ['http-01'] | ||||
|       }); | ||||
|  | ||||
|       // Set up event forwarding from SmartAcme | ||||
|       this.setupEventListeners(); | ||||
|  | ||||
|       // Start the SmartACME client | ||||
|       await this.smartAcme.start(); | ||||
|       console.log('ACME client initialized successfully'); | ||||
|     } catch (error) { | ||||
|       const errorMessage = error instanceof Error ? error.message : String(error); | ||||
|       throw new Error(`Failed to initialize ACME client: ${errorMessage}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Ensure the certificate store directory exists | ||||
|    */ | ||||
|   private async ensureCertificateStore(): Promise<void> { | ||||
|     try { | ||||
|       await plugins.fs.promises.mkdir(this.certificateStore, { recursive: true }); | ||||
|     } catch (error) { | ||||
|       const errorMessage = error instanceof Error ? error.message : String(error); | ||||
|       throw new Error(`Failed to create certificate store: ${errorMessage}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Setup event listeners to forward SmartACME events to our own event emitter | ||||
|    */ | ||||
|   private setupEventListeners(): void { | ||||
|     if (!this.smartAcme) return; | ||||
|  | ||||
|     const setupEvents = (emitter: { on: (event: string, listener: (data: any) => void) => void }) => { | ||||
|       // Forward certificate events | ||||
|       emitter.on('certificate', (data: any) => { | ||||
|         const isRenewal = !!data.isRenewal; | ||||
|  | ||||
|         const certData: ICertificateData = { | ||||
|           domain: data.domainName || data.domain, | ||||
|           certificate: data.publicKey || data.cert, | ||||
|           privateKey: data.privateKey || data.key, | ||||
|           expiryDate: new Date(data.validUntil || data.expiryDate || Date.now()), | ||||
|           source: 'http01', | ||||
|           isRenewal | ||||
|         }; | ||||
|  | ||||
|         const eventType = isRenewal | ||||
|           ? CertificateEvents.CERTIFICATE_RENEWED | ||||
|           : CertificateEvents.CERTIFICATE_ISSUED; | ||||
|  | ||||
|         this.emit(eventType, certData); | ||||
|       }); | ||||
|  | ||||
|       // Forward error events | ||||
|       emitter.on('error', (error: any) => { | ||||
|         const domain = error.domainName || error.domain || 'unknown'; | ||||
|         const failureData: ICertificateFailure = { | ||||
|           domain, | ||||
|           error: error.message || String(error), | ||||
|           isRenewal: !!error.isRenewal | ||||
|         }; | ||||
|  | ||||
|         this.emit(CertificateEvents.CERTIFICATE_FAILED, failureData); | ||||
|       }); | ||||
|     }; | ||||
|  | ||||
|     // Check for direct event methods on SmartAcme | ||||
|     if (typeof this.smartAcme.on === 'function') { | ||||
|       setupEvents(this.smartAcme as any); | ||||
|     } | ||||
|     // Check for eventEmitter property | ||||
|     else if (this.smartAcme.eventEmitter) { | ||||
|       setupEvents(this.smartAcme.eventEmitter); | ||||
|     } | ||||
|     // If no proper event handling, log a warning | ||||
|     else { | ||||
|       console.warn('SmartAcme instance does not support expected event interface - events may not be forwarded'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Handle HTTP request by checking if it's an ACME challenge | ||||
|    * @param req HTTP request object | ||||
|    * @param res HTTP response object | ||||
|    * @returns true if the request was handled, false otherwise | ||||
|    */ | ||||
|   public handleRequest(req: IncomingMessage, res: ServerResponse): boolean { | ||||
|     if (!this.http01Handler) return false; | ||||
|  | ||||
|     // Check if this is an ACME challenge request (/.well-known/acme-challenge/*) | ||||
|     const url = req.url || ''; | ||||
|     if (url.startsWith('/.well-known/acme-challenge/')) { | ||||
|       try { | ||||
|         // Delegate to the HTTP-01 memory handler, which knows how to serve challenges | ||||
|         this.http01Handler.handleRequest(req, res); | ||||
|         return true; | ||||
|       } catch (error) { | ||||
|         console.error('Error handling ACME challenge:', error); | ||||
|         // If there was an error, send a 404 response | ||||
|         res.writeHead(404); | ||||
|         res.end('Not found'); | ||||
|         return true; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Request a certificate for a domain | ||||
|    * @param domain Domain name to request a certificate for | ||||
|    * @param isRenewal Whether this is a renewal request | ||||
|    */ | ||||
|   public async requestCertificate(domain: string, isRenewal: boolean = false): Promise<ICertificateData> { | ||||
|     if (!this.smartAcme) { | ||||
|       throw new Error('ACME client not initialized'); | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       // Request certificate using SmartACME | ||||
|       const certObj = await this.smartAcme.getCertificateForDomain(domain); | ||||
|        | ||||
|       // Convert the certificate object to our CertificateData format | ||||
|       const certData: ICertificateData = { | ||||
|         domain, | ||||
|         certificate: certObj.publicKey, | ||||
|         privateKey: certObj.privateKey, | ||||
|         expiryDate: new Date(certObj.validUntil), | ||||
|         source: 'http01', | ||||
|         isRenewal | ||||
|       }; | ||||
|        | ||||
|       return certData; | ||||
|     } catch (error) { | ||||
|       // Create failure object | ||||
|       const failure: ICertificateFailure = { | ||||
|         domain, | ||||
|         error: error instanceof Error ? error.message : String(error), | ||||
|         isRenewal | ||||
|       }; | ||||
|        | ||||
|       // Emit failure event | ||||
|       this.emit(CertificateEvents.CERTIFICATE_FAILED, failure); | ||||
|        | ||||
|       // Rethrow with more context | ||||
|       throw new Error(`Failed to ${isRenewal ? 'renew' : 'obtain'} certificate for ${domain}: ${ | ||||
|         error instanceof Error ? error.message : String(error) | ||||
|       }`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Check if a certificate is expiring soon and trigger renewal if needed | ||||
|    * @param domain Domain name | ||||
|    * @param certificate Certificate data | ||||
|    * @param thresholdDays Days before expiry to trigger renewal | ||||
|    */ | ||||
|   public checkCertificateExpiry( | ||||
|     domain: string, | ||||
|     certificate: ICertificateData, | ||||
|     thresholdDays: number = 30 | ||||
|   ): void { | ||||
|     if (!certificate.expiryDate) return; | ||||
|      | ||||
|     const now = new Date(); | ||||
|     const expiryDate = certificate.expiryDate; | ||||
|     const daysDifference = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); | ||||
|      | ||||
|     if (daysDifference <= thresholdDays) { | ||||
|       const expiryInfo: ICertificateExpiring = { | ||||
|         domain, | ||||
|         expiryDate, | ||||
|         daysRemaining: daysDifference | ||||
|       }; | ||||
|        | ||||
|       this.emit(CertificateEvents.CERTIFICATE_EXPIRING, expiryInfo); | ||||
|        | ||||
|       // Automatically attempt renewal if expiring | ||||
|       if (this.smartAcme) { | ||||
|         this.requestCertificate(domain, true).catch(error => { | ||||
|           console.error(`Failed to auto-renew certificate for ${domain}:`, error); | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -1,13 +0,0 @@ | ||||
| /** | ||||
|  * Port 80 handling | ||||
|  */ | ||||
|  | ||||
| // Export the main components | ||||
| export { Port80Handler } from './port80-handler.js'; | ||||
| export { ChallengeResponder } from './challenge-responder.js'; | ||||
|  | ||||
| // Export backward compatibility interfaces and types | ||||
| export { | ||||
|   HttpError as Port80HandlerError, | ||||
|   CertificateError as CertError | ||||
| } from '../models/http-types.js'; | ||||
| @@ -1,728 +0,0 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
| import { IncomingMessage, ServerResponse } from 'http'; | ||||
| import { CertificateEvents } from '../../certificate/events/certificate-events.js'; | ||||
| import type { | ||||
|   IDomainOptions, // Kept for backward compatibility | ||||
|   ICertificateData, | ||||
|   ICertificateFailure, | ||||
|   ICertificateExpiring, | ||||
|   IAcmeOptions, | ||||
|   IRouteForwardConfig | ||||
| } from '../../certificate/models/certificate-types.js'; | ||||
| import { | ||||
|   HttpEvents, | ||||
|   HttpStatus, | ||||
|   HttpError, | ||||
|   CertificateError, | ||||
|   ServerError, | ||||
| } from '../models/http-types.js'; | ||||
| import type { IDomainCertificate } from '../models/http-types.js'; | ||||
| import { ChallengeResponder } from './challenge-responder.js'; | ||||
| import { extractPort80RoutesFromRoutes } from './acme-interfaces.js'; | ||||
| import type { IPort80RouteOptions } from './acme-interfaces.js'; | ||||
| import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js'; | ||||
|  | ||||
| // Re-export for backward compatibility | ||||
| export { | ||||
|   HttpError as Port80HandlerError, | ||||
|   CertificateError, | ||||
|   ServerError | ||||
| } | ||||
|  | ||||
| // Port80Handler events enum for backward compatibility | ||||
| export const Port80HandlerEvents = CertificateEvents; | ||||
|  | ||||
| /** | ||||
|  * Configuration options for the Port80Handler | ||||
|  */ | ||||
| // Port80Handler options moved to common types | ||||
|  | ||||
|  | ||||
| /** | ||||
|  * Port80Handler with ACME certificate management and request forwarding capabilities | ||||
|  * Now with glob pattern support for domain matching | ||||
|  */ | ||||
| export class Port80Handler extends plugins.EventEmitter { | ||||
|   private domainCertificates: Map<string, IDomainCertificate>; | ||||
|   private challengeResponder: ChallengeResponder | null = null; | ||||
|   private server: plugins.http.Server | null = null; | ||||
|  | ||||
|   // Renewal scheduling is handled externally by SmartProxy | ||||
|   private isShuttingDown: boolean = false; | ||||
|   private options: Required<IAcmeOptions>; | ||||
|  | ||||
|   /** | ||||
|    * Creates a new Port80Handler | ||||
|    * @param options Configuration options | ||||
|    */ | ||||
|   constructor(options: IAcmeOptions = {}) { | ||||
|     super(); | ||||
|     this.domainCertificates = new Map<string, IDomainCertificate>(); | ||||
|  | ||||
|     // Default options | ||||
|     this.options = { | ||||
|       port: options.port ?? 80, | ||||
|       accountEmail: options.accountEmail ?? 'admin@example.com', | ||||
|       useProduction: options.useProduction ?? false, // Safer default: staging | ||||
|       httpsRedirectPort: options.httpsRedirectPort ?? 443, | ||||
|       enabled: options.enabled ?? true, // Enable by default | ||||
|       certificateStore: options.certificateStore ?? './certs', | ||||
|       skipConfiguredCerts: options.skipConfiguredCerts ?? false, | ||||
|       renewThresholdDays: options.renewThresholdDays ?? 30, | ||||
|       renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24, | ||||
|       autoRenew: options.autoRenew ?? true, | ||||
|       routeForwards: options.routeForwards ?? [] | ||||
|     }; | ||||
|  | ||||
|     // Initialize challenge responder | ||||
|     if (this.options.enabled) { | ||||
|       this.challengeResponder = new ChallengeResponder( | ||||
|         this.options.useProduction, | ||||
|         this.options.accountEmail, | ||||
|         this.options.certificateStore | ||||
|       ); | ||||
|  | ||||
|       // Forward certificate events from the challenge responder | ||||
|       this.challengeResponder.on(CertificateEvents.CERTIFICATE_ISSUED, (data: ICertificateData) => { | ||||
|         this.emit(CertificateEvents.CERTIFICATE_ISSUED, data); | ||||
|       }); | ||||
|  | ||||
|       this.challengeResponder.on(CertificateEvents.CERTIFICATE_RENEWED, (data: ICertificateData) => { | ||||
|         this.emit(CertificateEvents.CERTIFICATE_RENEWED, data); | ||||
|       }); | ||||
|  | ||||
|       this.challengeResponder.on(CertificateEvents.CERTIFICATE_FAILED, (error: ICertificateFailure) => { | ||||
|         this.emit(CertificateEvents.CERTIFICATE_FAILED, error); | ||||
|       }); | ||||
|  | ||||
|       this.challengeResponder.on(CertificateEvents.CERTIFICATE_EXPIRING, (expiry: ICertificateExpiring) => { | ||||
|         this.emit(CertificateEvents.CERTIFICATE_EXPIRING, expiry); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Starts the HTTP server for ACME challenges | ||||
|    */ | ||||
|   public async start(): Promise<void> { | ||||
|     if (this.server) { | ||||
|       throw new ServerError('Server is already running'); | ||||
|     } | ||||
|  | ||||
|     if (this.isShuttingDown) { | ||||
|       throw new ServerError('Server is shutting down'); | ||||
|     } | ||||
|  | ||||
|     // Skip if disabled | ||||
|     if (this.options.enabled === false) { | ||||
|       console.log('Port80Handler is disabled, skipping start'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Initialize the challenge responder if enabled | ||||
|     if (this.options.enabled && this.challengeResponder) { | ||||
|       try { | ||||
|         await this.challengeResponder.initialize(); | ||||
|       } catch (error) { | ||||
|         throw new ServerError(`Failed to initialize challenge responder: ${ | ||||
|           error instanceof Error ? error.message : String(error) | ||||
|         }`); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return new Promise((resolve, reject) => { | ||||
|       try { | ||||
|         this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res)); | ||||
|  | ||||
|         this.server.on('error', (error: NodeJS.ErrnoException) => { | ||||
|           if (error.code === 'EACCES') { | ||||
|             reject(new ServerError(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`, error.code)); | ||||
|           } else if (error.code === 'EADDRINUSE') { | ||||
|             reject(new ServerError(`Port ${this.options.port} is already in use.`, error.code)); | ||||
|           } else { | ||||
|             reject(new ServerError(error.message, error.code)); | ||||
|           } | ||||
|         }); | ||||
|  | ||||
|         this.server.listen(this.options.port, () => { | ||||
|           console.log(`Port80Handler is listening on port ${this.options.port}`); | ||||
|           this.emit(CertificateEvents.MANAGER_STARTED, this.options.port); | ||||
|  | ||||
|           // Start certificate process for domains with acmeMaintenance enabled | ||||
|           for (const [domain, domainInfo] of this.domainCertificates.entries()) { | ||||
|             // Skip glob patterns for certificate issuance | ||||
|             if (this.isGlobPattern(domain)) { | ||||
|               console.log(`Skipping initial certificate for glob pattern: ${domain}`); | ||||
|               continue; | ||||
|             } | ||||
|  | ||||
|             if (domainInfo.options.acmeMaintenance && !domainInfo.certObtained && !domainInfo.obtainingInProgress) { | ||||
|               this.obtainCertificate(domain).catch(err => { | ||||
|                 console.error(`Error obtaining initial certificate for ${domain}:`, err); | ||||
|               }); | ||||
|             } | ||||
|           } | ||||
|  | ||||
|           resolve(); | ||||
|         }); | ||||
|       } catch (error) { | ||||
|         const message = error instanceof Error ? error.message : 'Unknown error starting server'; | ||||
|         reject(new ServerError(message)); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Stops the HTTP server and cleanup resources | ||||
|    */ | ||||
|   public async stop(): Promise<void> { | ||||
|     if (!this.server) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     this.isShuttingDown = true; | ||||
|  | ||||
|     return new Promise<void>((resolve) => { | ||||
|       if (this.server) { | ||||
|         this.server.close(() => { | ||||
|           this.server = null; | ||||
|           this.isShuttingDown = false; | ||||
|           this.emit(CertificateEvents.MANAGER_STOPPED); | ||||
|           resolve(); | ||||
|         }); | ||||
|       } else { | ||||
|         this.isShuttingDown = false; | ||||
|         resolve(); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Adds a domain with configuration options | ||||
|    * @param options Domain configuration options | ||||
|    */ | ||||
|   public addDomain(options: IDomainOptions | IPort80RouteOptions): void { | ||||
|     // Normalize options format (handle both IDomainOptions and IPort80RouteOptions) | ||||
|     const normalizedOptions: IDomainOptions = this.normalizeOptions(options); | ||||
|  | ||||
|     if (!normalizedOptions.domainName || typeof normalizedOptions.domainName !== 'string') { | ||||
|       throw new HttpError('Invalid domain name'); | ||||
|     } | ||||
|  | ||||
|     const domainName = normalizedOptions.domainName; | ||||
|  | ||||
|     if (!this.domainCertificates.has(domainName)) { | ||||
|       this.domainCertificates.set(domainName, { | ||||
|         options: normalizedOptions, | ||||
|         certObtained: false, | ||||
|         obtainingInProgress: false | ||||
|       }); | ||||
|  | ||||
|       console.log(`Domain added: ${domainName} with configuration:`, { | ||||
|         sslRedirect: normalizedOptions.sslRedirect, | ||||
|         acmeMaintenance: normalizedOptions.acmeMaintenance, | ||||
|         hasForward: !!normalizedOptions.forward, | ||||
|         hasAcmeForward: !!normalizedOptions.acmeForward, | ||||
|         routeReference: normalizedOptions.routeReference | ||||
|       }); | ||||
|  | ||||
|       // If acmeMaintenance is enabled and not a glob pattern, start certificate process immediately | ||||
|       if (normalizedOptions.acmeMaintenance && this.server && !this.isGlobPattern(domainName)) { | ||||
|         this.obtainCertificate(domainName).catch(err => { | ||||
|           console.error(`Error obtaining initial certificate for ${domainName}:`, err); | ||||
|         }); | ||||
|       } | ||||
|     } else { | ||||
|       // Update existing domain with new options | ||||
|       const existing = this.domainCertificates.get(domainName)!; | ||||
|       existing.options = normalizedOptions; | ||||
|       console.log(`Domain ${domainName} configuration updated`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Add domains from route configurations | ||||
|    * @param routes Array of route configurations | ||||
|    */ | ||||
|   public addDomainsFromRoutes(routes: IRouteConfig[]): void { | ||||
|     // Extract Port80RouteOptions from routes | ||||
|     const routeOptions = extractPort80RoutesFromRoutes(routes); | ||||
|  | ||||
|     // Add each domain | ||||
|     for (const options of routeOptions) { | ||||
|       this.addDomain(options); | ||||
|     } | ||||
|  | ||||
|     console.log(`Added ${routeOptions.length} domains from routes for certificate management`); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Normalize options from either IDomainOptions or IPort80RouteOptions | ||||
|    * @param options Options to normalize | ||||
|    * @returns Normalized IDomainOptions | ||||
|    * @private | ||||
|    */ | ||||
|   private normalizeOptions(options: IDomainOptions | IPort80RouteOptions): IDomainOptions { | ||||
|     // Handle IPort80RouteOptions format | ||||
|     if ('domain' in options) { | ||||
|       return { | ||||
|         domainName: options.domain, | ||||
|         sslRedirect: options.sslRedirect, | ||||
|         acmeMaintenance: options.acmeMaintenance, | ||||
|         forward: options.forward, | ||||
|         acmeForward: options.acmeForward, | ||||
|         routeReference: options.routeReference | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     // Already in IDomainOptions format | ||||
|     return options; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Removes a domain from management | ||||
|    * @param domain The domain to remove | ||||
|    */ | ||||
|   public removeDomain(domain: string): void { | ||||
|     if (this.domainCertificates.delete(domain)) { | ||||
|       console.log(`Domain removed: ${domain}`); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Gets the certificate for a domain if it exists | ||||
|    * @param domain The domain to get the certificate for | ||||
|    */ | ||||
|   public getCertificate(domain: string): ICertificateData | null { | ||||
|     // Can't get certificates for glob patterns | ||||
|     if (this.isGlobPattern(domain)) { | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     const domainInfo = this.domainCertificates.get(domain); | ||||
|  | ||||
|     if (!domainInfo || !domainInfo.certObtained || !domainInfo.certificate || !domainInfo.privateKey) { | ||||
|       return null; | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|       domain, | ||||
|       certificate: domainInfo.certificate, | ||||
|       privateKey: domainInfo.privateKey, | ||||
|       expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate() | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|  | ||||
|  | ||||
|   /** | ||||
|    * Check if a domain is a glob pattern | ||||
|    * @param domain Domain to check | ||||
|    * @returns True if the domain is a glob pattern | ||||
|    */ | ||||
|   private isGlobPattern(domain: string): boolean { | ||||
|     return domain.includes('*'); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Get domain info for a specific domain, using glob pattern matching if needed | ||||
|    * @param requestDomain The actual domain from the request | ||||
|    * @returns The domain info or null if not found | ||||
|    */ | ||||
|   private getDomainInfoForRequest(requestDomain: string): { domainInfo: IDomainCertificate, pattern: string } | null { | ||||
|     // Try direct match first | ||||
|     if (this.domainCertificates.has(requestDomain)) { | ||||
|       return { | ||||
|         domainInfo: this.domainCertificates.get(requestDomain)!, | ||||
|         pattern: requestDomain | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     // Then try glob patterns | ||||
|     for (const [pattern, domainInfo] of this.domainCertificates.entries()) { | ||||
|       if (this.isGlobPattern(pattern) && this.domainMatchesPattern(requestDomain, pattern)) { | ||||
|         return { domainInfo, pattern }; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return null; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Check if a domain matches a glob pattern | ||||
|    * @param domain The domain to check | ||||
|    * @param pattern The pattern to match against | ||||
|    * @returns True if the domain matches the pattern | ||||
|    */ | ||||
|   private domainMatchesPattern(domain: string, pattern: string): boolean { | ||||
|     // Handle different glob pattern styles | ||||
|     if (pattern.startsWith('*.')) { | ||||
|       // *.example.com matches any subdomain | ||||
|       const suffix = pattern.substring(2); | ||||
|       return domain.endsWith(suffix) && domain.includes('.') && domain !== suffix; | ||||
|     } else if (pattern.endsWith('.*')) { | ||||
|       // example.* matches any TLD | ||||
|       const prefix = pattern.substring(0, pattern.length - 2); | ||||
|       const domainParts = domain.split('.'); | ||||
|       return domain.startsWith(prefix + '.') && domainParts.length >= 2; | ||||
|     } else if (pattern === '*') { | ||||
|       // Wildcard matches everything | ||||
|       return true; | ||||
|     } else { | ||||
|       // Exact match (shouldn't reach here as we check exact matches first) | ||||
|       return domain === pattern; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|  | ||||
|   /** | ||||
|    * Handles incoming HTTP requests | ||||
|    * @param req The HTTP request | ||||
|    * @param res The HTTP response | ||||
|    */ | ||||
|   private handleRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void { | ||||
|     // Emit request received event with basic info | ||||
|     this.emit(HttpEvents.REQUEST_RECEIVED, { | ||||
|       url: req.url, | ||||
|       method: req.method, | ||||
|       headers: req.headers | ||||
|     }); | ||||
|  | ||||
|     const hostHeader = req.headers.host; | ||||
|     if (!hostHeader) { | ||||
|       res.statusCode = HttpStatus.BAD_REQUEST; | ||||
|       res.end('Bad Request: Host header is missing'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Extract domain (ignoring any port in the Host header) | ||||
|     const domain = hostHeader.split(':')[0]; | ||||
|  | ||||
|     // Check if this is an ACME challenge request that our ChallengeResponder can handle | ||||
|     if (this.challengeResponder && req.url?.startsWith('/.well-known/acme-challenge/')) { | ||||
|       // Handle ACME HTTP-01 challenge with the challenge responder | ||||
|       const domainMatch = this.getDomainInfoForRequest(domain); | ||||
|  | ||||
|       // If there's a specific ACME forwarding config for this domain, use that instead | ||||
|       if (domainMatch?.domainInfo.options.acmeForward) { | ||||
|         this.forwardRequest(req, res, domainMatch.domainInfo.options.acmeForward, 'ACME challenge'); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // If domain exists and has acmeMaintenance enabled, or we don't have the domain yet | ||||
|       // (for auto-provisioning), try to handle the ACME challenge | ||||
|       if (!domainMatch || domainMatch.domainInfo.options.acmeMaintenance) { | ||||
|         // Let the challenge responder try to handle this request | ||||
|         if (this.challengeResponder.handleRequest(req, res)) { | ||||
|           // Challenge was handled | ||||
|           return; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Dynamic provisioning: if domain not yet managed, register for ACME and return 503 | ||||
|     if (!this.domainCertificates.has(domain)) { | ||||
|       try { | ||||
|         this.addDomain({ domainName: domain, sslRedirect: false, acmeMaintenance: true }); | ||||
|       } catch (err) { | ||||
|         console.error(`Error registering domain for on-demand provisioning: ${err}`); | ||||
|       } | ||||
|       res.statusCode = HttpStatus.SERVICE_UNAVAILABLE; | ||||
|       res.end('Certificate issuance in progress'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Get domain config, using glob pattern matching if needed | ||||
|     const domainMatch = this.getDomainInfoForRequest(domain); | ||||
|     if (!domainMatch) { | ||||
|       res.statusCode = HttpStatus.NOT_FOUND; | ||||
|       res.end('Domain not configured'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const { domainInfo, pattern } = domainMatch; | ||||
|     const options = domainInfo.options; | ||||
|  | ||||
|     // Check if we should forward non-ACME requests | ||||
|     if (options.forward) { | ||||
|       this.forwardRequest(req, res, options.forward, 'HTTP'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // If certificate exists and sslRedirect is enabled, redirect to HTTPS | ||||
|     // (Skip for glob patterns as they won't have certificates) | ||||
|     if (!this.isGlobPattern(pattern) && domainInfo.certObtained && options.sslRedirect) { | ||||
|       const httpsPort = this.options.httpsRedirectPort; | ||||
|       const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`; | ||||
|       const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`; | ||||
|  | ||||
|       res.statusCode = HttpStatus.MOVED_PERMANENTLY; | ||||
|       res.setHeader('Location', redirectUrl); | ||||
|       res.end(`Redirecting to ${redirectUrl}`); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Handle case where certificate maintenance is enabled but not yet obtained | ||||
|     // (Skip for glob patterns as they can't have certificates) | ||||
|     if (!this.isGlobPattern(pattern) && options.acmeMaintenance && !domainInfo.certObtained) { | ||||
|       // Trigger certificate issuance if not already running | ||||
|       if (!domainInfo.obtainingInProgress) { | ||||
|         this.obtainCertificate(domain).catch(err => { | ||||
|           const errorMessage = err instanceof Error ? err.message : 'Unknown error'; | ||||
|           this.emit(CertificateEvents.CERTIFICATE_FAILED, { | ||||
|             domain, | ||||
|             error: errorMessage, | ||||
|             isRenewal: false | ||||
|           }); | ||||
|           console.error(`Error obtaining certificate for ${domain}:`, err); | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       res.statusCode = HttpStatus.SERVICE_UNAVAILABLE; | ||||
|       res.end('Certificate issuance in progress, please try again later.'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Default response for unhandled request | ||||
|     res.statusCode = HttpStatus.NOT_FOUND; | ||||
|     res.end('No handlers configured for this request'); | ||||
|  | ||||
|     // Emit request handled event | ||||
|     this.emit(HttpEvents.REQUEST_HANDLED, { | ||||
|       domain, | ||||
|       url: req.url, | ||||
|       statusCode: res.statusCode | ||||
|     }); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Forwards an HTTP request to the specified target | ||||
|    * @param req The original request | ||||
|    * @param res The response object | ||||
|    * @param target The forwarding target (IP and port) | ||||
|    * @param requestType Type of request for logging | ||||
|    */ | ||||
|   private forwardRequest( | ||||
|     req: plugins.http.IncomingMessage, | ||||
|     res: plugins.http.ServerResponse, | ||||
|     target: { ip: string; port: number }, | ||||
|     requestType: string | ||||
|   ): void { | ||||
|     const options = { | ||||
|       hostname: target.ip, | ||||
|       port: target.port, | ||||
|       path: req.url, | ||||
|       method: req.method, | ||||
|       headers: { ...req.headers } | ||||
|     }; | ||||
|  | ||||
|     const domain = req.headers.host?.split(':')[0] || 'unknown'; | ||||
|     console.log(`Forwarding ${requestType} request for ${domain} to ${target.ip}:${target.port}`); | ||||
|  | ||||
|     const proxyReq = plugins.http.request(options, (proxyRes) => { | ||||
|       // Copy status code | ||||
|       res.statusCode = proxyRes.statusCode || HttpStatus.INTERNAL_SERVER_ERROR; | ||||
|  | ||||
|       // Copy headers | ||||
|       for (const [key, value] of Object.entries(proxyRes.headers)) { | ||||
|         if (value) res.setHeader(key, value); | ||||
|       } | ||||
|  | ||||
|       // Pipe response data | ||||
|       proxyRes.pipe(res); | ||||
|  | ||||
|       this.emit(HttpEvents.REQUEST_FORWARDED, { | ||||
|         domain, | ||||
|         requestType, | ||||
|         target: `${target.ip}:${target.port}`, | ||||
|         statusCode: proxyRes.statusCode | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     proxyReq.on('error', (error) => { | ||||
|       console.error(`Error forwarding request to ${target.ip}:${target.port}:`, error); | ||||
|  | ||||
|       this.emit(HttpEvents.REQUEST_ERROR, { | ||||
|         domain, | ||||
|         error: error.message, | ||||
|         target: `${target.ip}:${target.port}` | ||||
|       }); | ||||
|  | ||||
|       if (!res.headersSent) { | ||||
|         res.statusCode = HttpStatus.INTERNAL_SERVER_ERROR; | ||||
|         res.end(`Proxy error: ${error.message}`); | ||||
|       } else { | ||||
|         res.end(); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     // Pipe original request to proxy request | ||||
|     if (req.readable) { | ||||
|       req.pipe(proxyReq); | ||||
|     } else { | ||||
|       proxyReq.end(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|  | ||||
|   /** | ||||
|    * Obtains a certificate for a domain using ACME HTTP-01 challenge | ||||
|    * @param domain The domain to obtain a certificate for | ||||
|    * @param isRenewal Whether this is a renewal attempt | ||||
|    */ | ||||
|   private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> { | ||||
|     if (this.isGlobPattern(domain)) { | ||||
|       throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal); | ||||
|     } | ||||
|  | ||||
|     const domainInfo = this.domainCertificates.get(domain)!; | ||||
|  | ||||
|     if (!domainInfo.options.acmeMaintenance) { | ||||
|       console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (domainInfo.obtainingInProgress) { | ||||
|       console.log(`Certificate issuance already in progress for ${domain}`); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (!this.challengeResponder) { | ||||
|       throw new HttpError('Challenge responder is not initialized'); | ||||
|     } | ||||
|  | ||||
|     domainInfo.obtainingInProgress = true; | ||||
|     domainInfo.lastRenewalAttempt = new Date(); | ||||
|  | ||||
|     try { | ||||
|       // Request certificate via ChallengeResponder | ||||
|       // The ChallengeResponder handles all ACME client interactions and will emit events | ||||
|       const certData = await this.challengeResponder.requestCertificate(domain, isRenewal); | ||||
|  | ||||
|       // Update domain info with certificate data | ||||
|       domainInfo.certificate = certData.certificate; | ||||
|       domainInfo.privateKey = certData.privateKey; | ||||
|       domainInfo.certObtained = true; | ||||
|       domainInfo.expiryDate = certData.expiryDate; | ||||
|  | ||||
|       console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`); | ||||
|     } catch (error: any) { | ||||
|       const errorMsg = error instanceof Error ? error.message : String(error); | ||||
|       console.error(`Error during certificate issuance for ${domain}:`, error); | ||||
|       throw new CertificateError(errorMsg, domain, isRenewal); | ||||
|     } finally { | ||||
|       domainInfo.obtainingInProgress = false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|    | ||||
|   /** | ||||
|    * Extract expiry date from certificate using a more robust approach | ||||
|    * @param certificate Certificate PEM string | ||||
|    * @param domain Domain for logging | ||||
|    * @returns Extracted expiry date or default | ||||
|    */ | ||||
|   private extractExpiryDateFromCertificate(certificate: string, domain: string): Date { | ||||
|     try { | ||||
|       // This is still using regex, but in a real implementation you would use | ||||
|       // a library like node-forge or x509 to properly parse the certificate | ||||
|       const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i); | ||||
|       if (matches && matches[1]) { | ||||
|         const expiryDate = new Date(matches[1]); | ||||
|          | ||||
|         // Validate that we got a valid date | ||||
|         if (!isNaN(expiryDate.getTime())) { | ||||
|           console.log(`Certificate for ${domain} will expire on ${expiryDate.toISOString()}`); | ||||
|           return expiryDate; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       console.warn(`Could not extract valid expiry date from certificate for ${domain}, using default`); | ||||
|       return this.getDefaultExpiryDate(); | ||||
|     } catch (error) { | ||||
|       console.warn(`Failed to extract expiry date from certificate for ${domain}, using default`); | ||||
|       return this.getDefaultExpiryDate(); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Get a default expiry date (90 days from now) | ||||
|    * @returns Default expiry date | ||||
|    */ | ||||
|   private getDefaultExpiryDate(): Date { | ||||
|     return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); // 90 days default | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Emits a certificate event with the certificate data | ||||
|    * @param eventType The event type to emit | ||||
|    * @param data The certificate data | ||||
|    */ | ||||
|   private emitCertificateEvent(eventType: CertificateEvents, data: ICertificateData): void { | ||||
|     this.emit(eventType, data); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Gets all domains and their certificate status | ||||
|    * @returns Map of domains to certificate status | ||||
|    */ | ||||
|   public getDomainCertificateStatus(): Map<string, { | ||||
|     certObtained: boolean; | ||||
|     expiryDate?: Date; | ||||
|     daysRemaining?: number; | ||||
|     obtainingInProgress: boolean; | ||||
|     lastRenewalAttempt?: Date; | ||||
|   }> { | ||||
|     const result = new Map<string, { | ||||
|       certObtained: boolean; | ||||
|       expiryDate?: Date; | ||||
|       daysRemaining?: number; | ||||
|       obtainingInProgress: boolean; | ||||
|       lastRenewalAttempt?: Date; | ||||
|     }>(); | ||||
|      | ||||
|     const now = new Date(); | ||||
|      | ||||
|     for (const [domain, domainInfo] of this.domainCertificates.entries()) { | ||||
|       // Skip glob patterns | ||||
|       if (this.isGlobPattern(domain)) continue; | ||||
|        | ||||
|       const status: { | ||||
|         certObtained: boolean; | ||||
|         expiryDate?: Date; | ||||
|         daysRemaining?: number; | ||||
|         obtainingInProgress: boolean; | ||||
|         lastRenewalAttempt?: Date; | ||||
|       } = { | ||||
|         certObtained: domainInfo.certObtained, | ||||
|         expiryDate: domainInfo.expiryDate, | ||||
|         obtainingInProgress: domainInfo.obtainingInProgress, | ||||
|         lastRenewalAttempt: domainInfo.lastRenewalAttempt | ||||
|       }; | ||||
|        | ||||
|       // Calculate days remaining if expiry date is available | ||||
|       if (domainInfo.expiryDate) { | ||||
|         const daysRemaining = Math.ceil( | ||||
|           (domainInfo.expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000) | ||||
|         ); | ||||
|         status.daysRemaining = daysRemaining; | ||||
|       } | ||||
|        | ||||
|       result.set(domain, status); | ||||
|     } | ||||
|      | ||||
|     return result; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Request a certificate renewal for a specific domain. | ||||
|    * @param domain The domain to renew. | ||||
|    */ | ||||
|   public async renewCertificate(domain: string): Promise<void> { | ||||
|     if (!this.domainCertificates.has(domain)) { | ||||
|       throw new HttpError(`Domain not managed: ${domain}`); | ||||
|     } | ||||
|     // Trigger renewal via ACME | ||||
|     await this.obtainCertificate(domain, true); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										25
									
								
								ts/index.ts
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								ts/index.ts
									
									
									
									
									
								
							| @@ -9,39 +9,36 @@ export * from './proxies/nftables-proxy/index.js'; | ||||
| // Export NetworkProxy elements selectively to avoid RouteManager ambiguity | ||||
| export { NetworkProxy, CertificateManager, ConnectionPool, RequestHandler, WebSocketHandler } from './proxies/network-proxy/index.js'; | ||||
| export type { IMetricsTracker, MetricsTracker } from './proxies/network-proxy/index.js'; | ||||
| export * from './proxies/network-proxy/models/index.js'; | ||||
| // Export models except IAcmeOptions to avoid conflict | ||||
| export type { INetworkProxyOptions, ICertificateEntry, ILogger } from './proxies/network-proxy/models/types.js'; | ||||
| export { RouteManager as NetworkProxyRouteManager } from './proxies/network-proxy/models/types.js'; | ||||
|  | ||||
| // Export port80handler elements selectively to avoid conflicts | ||||
| export { | ||||
|   Port80Handler, | ||||
|   Port80HandlerError as HttpError, | ||||
|   ServerError, | ||||
|   CertificateError | ||||
| } from './http/port80/port80-handler.js'; | ||||
| // Use re-export to control the names | ||||
| export { Port80HandlerEvents } from './certificate/events/certificate-events.js'; | ||||
| // Certificate and Port80 modules have been removed - use SmartCertManager instead | ||||
|  | ||||
| export * from './redirect/classes.redirect.js'; | ||||
|  | ||||
| // Export SmartProxy elements selectively to avoid RouteManager ambiguity | ||||
| export { SmartProxy, ConnectionManager, SecurityManager, TimeoutManager, TlsManager, NetworkProxyBridge, RouteConnectionHandler } from './proxies/smart-proxy/index.js'; | ||||
| export { RouteManager } from './proxies/smart-proxy/route-manager.js'; | ||||
| export * from './proxies/smart-proxy/models/index.js'; | ||||
| // Export smart-proxy models | ||||
| export type { ISmartProxyOptions, IConnectionRecord, IRouteConfig, IRouteMatch, IRouteAction, IRouteTls, IRouteContext } from './proxies/smart-proxy/models/index.js'; | ||||
| export type { TSmartProxyCertProvisionObject } from './proxies/smart-proxy/models/interfaces.js'; | ||||
| export * from './proxies/smart-proxy/utils/index.js'; | ||||
|  | ||||
| // Original: export * from './smartproxy/classes.pp.snihandler.js' | ||||
| // Now we export from the new module | ||||
| export { SniHandler } from './tls/sni/sni-handler.js'; | ||||
| // Original: export * from './smartproxy/classes.pp.interfaces.js' | ||||
| // Now we export from the new module | ||||
| export * from './proxies/smart-proxy/models/interfaces.js'; | ||||
| // Now we export from the new module (selectively to avoid conflicts) | ||||
|  | ||||
| // Core types and utilities | ||||
| export * from './core/models/common-types.js'; | ||||
|  | ||||
| // Export IAcmeOptions from one place only | ||||
| export type { IAcmeOptions } from './proxies/smart-proxy/models/interfaces.js'; | ||||
|  | ||||
| // Modular exports for new architecture | ||||
| export * as forwarding from './forwarding/index.js'; | ||||
| export * as certificate from './certificate/index.js'; | ||||
| // Certificate module has been removed - use SmartCertManager instead | ||||
| export * as tls from './tls/index.js'; | ||||
| export * as http from './http/index.js'; | ||||
| @@ -2,16 +2,19 @@ | ||||
|  * Proxy implementations module | ||||
|  */ | ||||
|  | ||||
| // Export NetworkProxy with selective imports to avoid RouteManager ambiguity | ||||
| // Export NetworkProxy with selective imports to avoid conflicts | ||||
| export { NetworkProxy, CertificateManager, ConnectionPool, RequestHandler, WebSocketHandler } from './network-proxy/index.js'; | ||||
| export type { IMetricsTracker, MetricsTracker } from './network-proxy/index.js'; | ||||
| export * from './network-proxy/models/index.js'; | ||||
| // Export network-proxy models except IAcmeOptions | ||||
| export type { INetworkProxyOptions, ICertificateEntry, ILogger } from './network-proxy/models/types.js'; | ||||
| export { RouteManager as NetworkProxyRouteManager } from './network-proxy/models/types.js'; | ||||
|  | ||||
| // Export SmartProxy with selective imports to avoid RouteManager ambiguity | ||||
| // Export SmartProxy with selective imports to avoid conflicts | ||||
| export { SmartProxy, ConnectionManager, SecurityManager, TimeoutManager, TlsManager, NetworkProxyBridge, RouteConnectionHandler } from './smart-proxy/index.js'; | ||||
| export { RouteManager as SmartProxyRouteManager } from './smart-proxy/route-manager.js'; | ||||
| export * from './smart-proxy/utils/index.js'; | ||||
| export * from './smart-proxy/models/index.js'; | ||||
| // Export smart-proxy models except IAcmeOptions | ||||
| export type { ISmartProxyOptions, IConnectionRecord, IRouteConfig, IRouteMatch, IRouteAction, IRouteTls, IRouteContext } from './smart-proxy/models/index.js'; | ||||
|  | ||||
| // Export NFTables proxy (no conflicts) | ||||
| export * from './nftables-proxy/index.js'; | ||||
|   | ||||
| @@ -3,21 +3,17 @@ import * as fs from 'fs'; | ||||
| import * as path from 'path'; | ||||
| import { fileURLToPath } from 'url'; | ||||
| import { type INetworkProxyOptions, type ICertificateEntry, type ILogger, createLogger } from './models/types.js'; | ||||
| import { Port80Handler } from '../../http/port80/port80-handler.js'; | ||||
| import { CertificateEvents } from '../../certificate/events/certificate-events.js'; | ||||
| import { buildPort80Handler } from '../../certificate/acme/acme-factory.js'; | ||||
| import { subscribeToPort80Handler } from '../../core/utils/event-utils.js'; | ||||
| import type { IDomainOptions } from '../../certificate/models/certificate-types.js'; | ||||
| import type { IRouteConfig } from '../smart-proxy/models/route-types.js'; | ||||
|  | ||||
| /** | ||||
|  * Manages SSL certificates for NetworkProxy including ACME integration | ||||
|  * @deprecated This class is deprecated. Use SmartCertManager instead. | ||||
|  *  | ||||
|  * This is a stub implementation that maintains backward compatibility | ||||
|  * while the functionality has been moved to SmartCertManager. | ||||
|  */ | ||||
| export class CertificateManager { | ||||
|   private defaultCertificates: { key: string; cert: string }; | ||||
|   private certificateCache: Map<string, ICertificateEntry> = new Map(); | ||||
|   private port80Handler: Port80Handler | null = null; | ||||
|   private externalPort80Handler: boolean = false; | ||||
|   private certificateStoreDir: string; | ||||
|   private logger: ILogger; | ||||
|   private httpsServer: plugins.https.Server | null = null; | ||||
| @@ -26,6 +22,8 @@ export class CertificateManager { | ||||
|     this.certificateStoreDir = path.resolve(options.acme?.certificateStore || './certs'); | ||||
|     this.logger = createLogger(options.logLevel || 'info'); | ||||
|      | ||||
|     this.logger.warn('CertificateManager is deprecated - use SmartCertManager instead'); | ||||
|      | ||||
|     // Ensure certificate store directory exists | ||||
|     try { | ||||
|       if (!fs.existsSync(this.certificateStoreDir)) { | ||||
| @@ -44,7 +42,6 @@ export class CertificateManager { | ||||
|    */ | ||||
|   public loadDefaultCertificates(): void { | ||||
|     const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||||
|     // Fix the path to look for certificates at the project root instead of inside ts directory | ||||
|     const certPath = path.join(__dirname, '..', '..', '..', 'assets', 'certs'); | ||||
|  | ||||
|     try { | ||||
| @@ -52,467 +49,145 @@ export class CertificateManager { | ||||
|         key: fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'), | ||||
|         cert: fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8') | ||||
|       }; | ||||
|       this.logger.info('Default certificates loaded successfully'); | ||||
|       this.logger.info('Loaded default certificates from filesystem'); | ||||
|     } catch (error) { | ||||
|       this.logger.error('Error loading default certificates', error); | ||||
|  | ||||
|       // Generate self-signed fallback certificates | ||||
|       try { | ||||
|         // This is a placeholder for actual certificate generation code | ||||
|         // In a real implementation, you would use a library like selfsigned to generate certs | ||||
|         this.defaultCertificates = { | ||||
|           key: "FALLBACK_KEY_CONTENT", | ||||
|           cert: "FALLBACK_CERT_CONTENT" | ||||
|         }; | ||||
|         this.logger.warn('Using fallback self-signed certificates'); | ||||
|       } catch (fallbackError) { | ||||
|         this.logger.error('Failed to generate fallback certificates', fallbackError); | ||||
|         throw new Error('Could not load or generate SSL certificates'); | ||||
|       } | ||||
|       this.logger.error(`Failed to load default certificates: ${error}`); | ||||
|       this.generateSelfSignedCertificate(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Set the HTTPS server reference for context updates | ||||
|    * Generates self-signed certificates as fallback | ||||
|    */ | ||||
|   private generateSelfSignedCertificate(): void { | ||||
|     // Generate a self-signed certificate using forge or similar | ||||
|     // For now, just use a placeholder | ||||
|     const selfSignedCert = `-----BEGIN CERTIFICATE----- | ||||
| MIIBkTCB+wIJAKHHIgIIA0/cMA0GCSqGSIb3DQEBBQUAMA0xCzAJBgNVBAYTAlVT | ||||
| MB4XDTE0MDEwMTAwMDAwMFoXDTI0MDEwMTAwMDAwMFowDTELMAkGA1UEBhMCVVMw | ||||
| gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAMRiH0VwnOH3jCV7c6JFZWYrvuqy | ||||
| -----END CERTIFICATE-----`; | ||||
|      | ||||
|     const selfSignedKey = `-----BEGIN PRIVATE KEY----- | ||||
| MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMRiH0VwnOH3jCV7 | ||||
| c6JFZWYrvuqyALCLXj0pcr1iqNdHjegNXnkl5zjdaUjq4edNOKl7M1AlFiYjG2xk | ||||
| -----END PRIVATE KEY-----`; | ||||
|  | ||||
|     this.defaultCertificates = { | ||||
|       key: selfSignedKey, | ||||
|       cert: selfSignedCert | ||||
|     }; | ||||
|  | ||||
|     this.logger.warn('Using self-signed certificate as fallback'); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Gets the default certificates | ||||
|    */ | ||||
|   public getDefaultCertificates(): { key: string; cert: string } { | ||||
|     return this.defaultCertificates; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * @deprecated Use SmartCertManager instead | ||||
|    */ | ||||
|   public setExternalPort80Handler(handler: any): void { | ||||
|     this.logger.warn('setExternalPort80Handler is deprecated - use SmartCertManager instead'); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * @deprecated Use SmartCertManager instead | ||||
|    */ | ||||
|   public async updateRoutes(routes: IRouteConfig[]): Promise<void> { | ||||
|     this.logger.warn('updateRoutes is deprecated - use SmartCertManager instead'); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Handles SNI callback to provide appropriate certificate | ||||
|    */ | ||||
|   public handleSNI(domain: string, cb: (err: Error | null, ctx: plugins.tls.SecureContext) => void): void { | ||||
|     const certificate = this.getCachedCertificate(domain); | ||||
|      | ||||
|     if (certificate) { | ||||
|       const context = plugins.tls.createSecureContext({ | ||||
|         key: certificate.key, | ||||
|         cert: certificate.cert | ||||
|       }); | ||||
|       cb(null, context); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     // Use default certificate if no domain-specific certificate found | ||||
|     const defaultContext = plugins.tls.createSecureContext({ | ||||
|       key: this.defaultCertificates.key, | ||||
|       cert: this.defaultCertificates.cert | ||||
|     }); | ||||
|     cb(null, defaultContext); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Updates a certificate in the cache | ||||
|    */ | ||||
|   public updateCertificate(domain: string, cert: string, key: string): void { | ||||
|     this.certificateCache.set(domain, { | ||||
|       cert, | ||||
|       key, | ||||
|       expires: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days | ||||
|     }); | ||||
|      | ||||
|     this.logger.info(`Certificate updated for ${domain}`); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Gets a cached certificate | ||||
|    */ | ||||
|   private getCachedCertificate(domain: string): ICertificateEntry | null { | ||||
|     return this.certificateCache.get(domain) || null; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * @deprecated Use SmartCertManager instead | ||||
|    */ | ||||
|   public async initializePort80Handler(): Promise<any> { | ||||
|     this.logger.warn('initializePort80Handler is deprecated - use SmartCertManager instead'); | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * @deprecated Use SmartCertManager instead | ||||
|    */ | ||||
|   public async stopPort80Handler(): Promise<void> { | ||||
|     this.logger.warn('stopPort80Handler is deprecated - use SmartCertManager instead'); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * @deprecated Use SmartCertManager instead | ||||
|    */ | ||||
|   public registerDomainsWithPort80Handler(domains: string[]): void { | ||||
|     this.logger.warn('registerDomainsWithPort80Handler is deprecated - use SmartCertManager instead'); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * @deprecated Use SmartCertManager instead | ||||
|    */ | ||||
|   public registerRoutesWithPort80Handler(routes: IRouteConfig[]): void { | ||||
|     this.logger.warn('registerRoutesWithPort80Handler is deprecated - use SmartCertManager instead'); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Sets the HTTPS server for certificate updates | ||||
|    */ | ||||
|   public setHttpsServer(server: plugins.https.Server): void { | ||||
|     this.httpsServer = server; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Get default certificates | ||||
|    * Gets statistics for metrics | ||||
|    */ | ||||
|   public getDefaultCertificates(): { key: string; cert: string } { | ||||
|     return { ...this.defaultCertificates }; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Sets an external Port80Handler for certificate management | ||||
|    */ | ||||
|   public setExternalPort80Handler(handler: Port80Handler): void { | ||||
|     if (this.port80Handler && !this.externalPort80Handler) { | ||||
|       this.logger.warn('Replacing existing internal Port80Handler with external handler'); | ||||
|  | ||||
|       // Clean up existing handler if needed | ||||
|       if (this.port80Handler !== handler) { | ||||
|         // Unregister event handlers to avoid memory leaks | ||||
|         this.port80Handler.removeAllListeners(CertificateEvents.CERTIFICATE_ISSUED); | ||||
|         this.port80Handler.removeAllListeners(CertificateEvents.CERTIFICATE_RENEWED); | ||||
|         this.port80Handler.removeAllListeners(CertificateEvents.CERTIFICATE_FAILED); | ||||
|         this.port80Handler.removeAllListeners(CertificateEvents.CERTIFICATE_EXPIRING); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Set the external handler | ||||
|     this.port80Handler = handler; | ||||
|     this.externalPort80Handler = true; | ||||
|  | ||||
|     // Subscribe to Port80Handler events | ||||
|     subscribeToPort80Handler(this.port80Handler, { | ||||
|       onCertificateIssued: this.handleCertificateIssued.bind(this), | ||||
|       onCertificateRenewed: this.handleCertificateIssued.bind(this), | ||||
|       onCertificateFailed: this.handleCertificateFailed.bind(this), | ||||
|       onCertificateExpiring: (data) => { | ||||
|         this.logger.info(`Certificate for ${data.domain} expires in ${data.daysRemaining} days`); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     this.logger.info('External Port80Handler connected to CertificateManager'); | ||||
|  | ||||
|     // Register domains with Port80Handler if we have any certificates cached | ||||
|     if (this.certificateCache.size > 0) { | ||||
|       const domains = Array.from(this.certificateCache.keys()) | ||||
|         .filter(domain => !domain.includes('*')); // Skip wildcard domains | ||||
|  | ||||
|       this.registerDomainsWithPort80Handler(domains); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Update route configurations managed by this certificate manager | ||||
|    * This method is called when route configurations change | ||||
|    * | ||||
|    * @param routes Array of route configurations | ||||
|    */ | ||||
|   public updateRouteConfigs(routes: IRouteConfig[]): void { | ||||
|     if (!this.port80Handler) { | ||||
|       this.logger.warn('Cannot update routes - Port80Handler is not initialized'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Register domains from routes with Port80Handler | ||||
|     this.registerRoutesWithPort80Handler(routes); | ||||
|  | ||||
|     // Process individual routes for certificate requirements | ||||
|     for (const route of routes) { | ||||
|       this.processRouteForCertificates(route); | ||||
|     } | ||||
|  | ||||
|     this.logger.info(`Updated certificate management for ${routes.length} routes`); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Handle newly issued or renewed certificates from Port80Handler | ||||
|    */ | ||||
|   private handleCertificateIssued(data: { domain: string; certificate: string; privateKey: string; expiryDate: Date }): void { | ||||
|     const { domain, certificate, privateKey, expiryDate } = data; | ||||
|      | ||||
|     this.logger.info(`Certificate ${this.certificateCache.has(domain) ? 'renewed' : 'issued'} for ${domain}, valid until ${expiryDate.toISOString()}`); | ||||
|      | ||||
|     // Update certificate in HTTPS server | ||||
|     this.updateCertificateCache(domain, certificate, privateKey, expiryDate); | ||||
|      | ||||
|     // Save the certificate to the filesystem if not using external handler | ||||
|     if (!this.externalPort80Handler && this.options.acme?.certificateStore) { | ||||
|       this.saveCertificateToStore(domain, certificate, privateKey); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Handle certificate issuance failures | ||||
|    */ | ||||
|   private handleCertificateFailed(data: { domain: string; error: string }): void { | ||||
|     this.logger.error(`Certificate issuance failed for ${data.domain}: ${data.error}`); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Saves certificate and private key to the filesystem | ||||
|    */ | ||||
|   private saveCertificateToStore(domain: string, certificate: string, privateKey: string): void { | ||||
|     try { | ||||
|       const certPath = path.join(this.certificateStoreDir, `${domain}.cert.pem`); | ||||
|       const keyPath = path.join(this.certificateStoreDir, `${domain}.key.pem`); | ||||
|        | ||||
|       fs.writeFileSync(certPath, certificate); | ||||
|       fs.writeFileSync(keyPath, privateKey); | ||||
|        | ||||
|       // Ensure private key has restricted permissions | ||||
|       try { | ||||
|         fs.chmodSync(keyPath, 0o600); | ||||
|       } catch (error) { | ||||
|         this.logger.warn(`Failed to set permissions on private key for ${domain}: ${error}`); | ||||
|       } | ||||
|        | ||||
|       this.logger.info(`Saved certificate for ${domain} to ${certPath}`); | ||||
|     } catch (error) { | ||||
|       this.logger.error(`Failed to save certificate for ${domain}: ${error}`); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Handles SNI (Server Name Indication) for TLS connections | ||||
|    * Used by the HTTPS server to select the correct certificate for each domain | ||||
|    */ | ||||
|   public handleSNI(domain: string, cb: (err: Error | null, ctx: plugins.tls.SecureContext) => void): void { | ||||
|     this.logger.debug(`SNI request for domain: ${domain}`); | ||||
|      | ||||
|     // Check if we have a certificate for this domain | ||||
|     const certs = this.certificateCache.get(domain); | ||||
|     if (certs) { | ||||
|       try { | ||||
|         // Create TLS context with the cached certificate | ||||
|         const context = plugins.tls.createSecureContext({ | ||||
|           key: certs.key, | ||||
|           cert: certs.cert | ||||
|         }); | ||||
|         this.logger.debug(`Using cached certificate for ${domain}`); | ||||
|         cb(null, context); | ||||
|         return; | ||||
|       } catch (err) { | ||||
|         this.logger.error(`Error creating secure context for ${domain}:`, err); | ||||
|       } | ||||
|     } | ||||
|     // No existing certificate: trigger dynamic provisioning via Port80Handler | ||||
|     if (this.port80Handler) { | ||||
|       try { | ||||
|         this.logger.info(`Triggering on-demand certificate retrieval for ${domain}`); | ||||
|         this.port80Handler.addDomain({ | ||||
|           domainName: domain, | ||||
|           sslRedirect: false, | ||||
|           acmeMaintenance: true | ||||
|         }); | ||||
|       } catch (err) { | ||||
|         this.logger.error(`Error registering domain for on-demand certificate: ${domain}`, err); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // Check if we should trigger certificate issuance | ||||
|     if (this.options.acme?.enabled && this.port80Handler && !domain.includes('*')) { | ||||
|       // Check if this domain is already registered | ||||
|       const certData = this.port80Handler.getCertificate(domain); | ||||
|        | ||||
|       if (!certData) { | ||||
|         this.logger.info(`No certificate found for ${domain}, registering for issuance`); | ||||
|  | ||||
|         // Register with new domain options format | ||||
|         const domainOptions: IDomainOptions = { | ||||
|           domainName: domain, | ||||
|           sslRedirect: true, | ||||
|           acmeMaintenance: true | ||||
|   public getStats() { | ||||
|     return { | ||||
|       cachedCertificates: this.certificateCache.size, | ||||
|       defaultCertEnabled: true | ||||
|     }; | ||||
|          | ||||
|         this.port80Handler.addDomain(domainOptions); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // Fall back to default certificate | ||||
|     try { | ||||
|       const context = plugins.tls.createSecureContext({ | ||||
|         key: this.defaultCertificates.key, | ||||
|         cert: this.defaultCertificates.cert | ||||
|       }); | ||||
|        | ||||
|       this.logger.debug(`Using default certificate for ${domain}`); | ||||
|       cb(null, context); | ||||
|     } catch (err) { | ||||
|       this.logger.error(`Error creating default secure context:`, err); | ||||
|       cb(new Error('Cannot create secure context'), null); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Updates certificate in cache | ||||
|    */ | ||||
|   public updateCertificateCache(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void { | ||||
|     // Update certificate context in HTTPS server if it's running | ||||
|     if (this.httpsServer) { | ||||
|       try { | ||||
|         this.httpsServer.addContext(domain, { | ||||
|           key: privateKey, | ||||
|           cert: certificate | ||||
|         }); | ||||
|         this.logger.debug(`Updated SSL context for domain: ${domain}`); | ||||
|       } catch (error) { | ||||
|         this.logger.error(`Error updating SSL context for domain ${domain}:`, error); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // Update certificate in cache | ||||
|     this.certificateCache.set(domain, { | ||||
|       key: privateKey, | ||||
|       cert: certificate, | ||||
|       expires: expiryDate | ||||
|     }); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Gets a certificate for a domain | ||||
|    */ | ||||
|   public getCertificate(domain: string): ICertificateEntry | undefined { | ||||
|     return this.certificateCache.get(domain); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Requests a new certificate for a domain | ||||
|    */ | ||||
|   public async requestCertificate(domain: string): Promise<boolean> { | ||||
|     if (!this.options.acme?.enabled && !this.externalPort80Handler) { | ||||
|       this.logger.warn('ACME certificate management is not enabled'); | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     if (!this.port80Handler) { | ||||
|       this.logger.error('Port80Handler is not initialized'); | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     // Skip wildcard domains - can't get certs for these with HTTP-01 validation | ||||
|     if (domain.includes('*')) { | ||||
|       this.logger.error(`Cannot request certificate for wildcard domain: ${domain}`); | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       // Use the new domain options format | ||||
|       const domainOptions: IDomainOptions = { | ||||
|         domainName: domain, | ||||
|         sslRedirect: true, | ||||
|         acmeMaintenance: true | ||||
|       }; | ||||
|        | ||||
|       this.port80Handler.addDomain(domainOptions); | ||||
|       this.logger.info(`Certificate request submitted for domain: ${domain}`); | ||||
|       return true; | ||||
|     } catch (error) { | ||||
|       this.logger.error(`Error requesting certificate for domain ${domain}:`, error); | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Registers domains with Port80Handler for ACME certificate management | ||||
|    * @param domains String array of domains to register | ||||
|    */ | ||||
|   public registerDomainsWithPort80Handler(domains: string[]): void { | ||||
|     if (!this.port80Handler) { | ||||
|       this.logger.warn('Port80Handler is not initialized'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     for (const domain of domains) { | ||||
|       // Skip wildcard domains - can't get certs for these with HTTP-01 validation | ||||
|       if (domain.includes('*')) { | ||||
|         this.logger.info(`Skipping wildcard domain for ACME: ${domain}`); | ||||
|         continue; | ||||
|       } | ||||
|  | ||||
|       // Skip domains already with certificates if configured to do so | ||||
|       if (this.options.acme?.skipConfiguredCerts) { | ||||
|         const cachedCert = this.certificateCache.get(domain); | ||||
|         if (cachedCert) { | ||||
|           this.logger.info(`Skipping domain with existing certificate: ${domain}`); | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       // Register the domain for certificate issuance with new domain options format | ||||
|       const domainOptions: IDomainOptions = { | ||||
|         domainName: domain, | ||||
|         sslRedirect: true, | ||||
|         acmeMaintenance: true | ||||
|       }; | ||||
|  | ||||
|       this.port80Handler.addDomain(domainOptions); | ||||
|       this.logger.info(`Registered domain for ACME certificate issuance: ${domain}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Extract domains from route configurations and register with Port80Handler | ||||
|    * This method enables direct integration with route-based configuration | ||||
|    * | ||||
|    * @param routes Array of route configurations | ||||
|    */ | ||||
|   public registerRoutesWithPort80Handler(routes: IRouteConfig[]): void { | ||||
|     if (!this.port80Handler) { | ||||
|       this.logger.warn('Port80Handler is not initialized'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Extract domains from route configurations | ||||
|     const domains: Set<string> = new Set(); | ||||
|  | ||||
|     for (const route of routes) { | ||||
|       // Skip disabled routes | ||||
|       if (route.enabled === false) { | ||||
|         continue; | ||||
|       } | ||||
|  | ||||
|       // Skip routes without HTTPS termination | ||||
|       if (route.action.type !== 'forward' || route.action.tls?.mode !== 'terminate') { | ||||
|         continue; | ||||
|       } | ||||
|  | ||||
|       // Extract domains from match criteria | ||||
|       if (route.match.domains) { | ||||
|         if (typeof route.match.domains === 'string') { | ||||
|           domains.add(route.match.domains); | ||||
|         } else if (Array.isArray(route.match.domains)) { | ||||
|           for (const domain of route.match.domains) { | ||||
|             domains.add(domain); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Register extracted domains | ||||
|     this.registerDomainsWithPort80Handler(Array.from(domains)); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Process a route config to determine if it requires automatic certificate provisioning | ||||
|    * @param route Route configuration to process | ||||
|    */ | ||||
|   public processRouteForCertificates(route: IRouteConfig): void { | ||||
|     // Skip disabled routes | ||||
|     if (route.enabled === false) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Skip routes without HTTPS termination or auto certificate | ||||
|     if (route.action.type !== 'forward' || | ||||
|         route.action.tls?.mode !== 'terminate' || | ||||
|         route.action.tls?.certificate !== 'auto') { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Extract domains from match criteria | ||||
|     const domains: string[] = []; | ||||
|     if (route.match.domains) { | ||||
|       if (typeof route.match.domains === 'string') { | ||||
|         domains.push(route.match.domains); | ||||
|       } else if (Array.isArray(route.match.domains)) { | ||||
|         domains.push(...route.match.domains); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Request certificates for the domains | ||||
|     for (const domain of domains) { | ||||
|       if (!domain.includes('*')) { // Skip wildcard domains | ||||
|         this.requestCertificate(domain).catch(err => { | ||||
|           this.logger.error(`Error requesting certificate for domain ${domain}:`, err); | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Initialize internal Port80Handler | ||||
|    */ | ||||
|   public async initializePort80Handler(): Promise<Port80Handler | null> { | ||||
|     // Skip if using external handler | ||||
|     if (this.externalPort80Handler) { | ||||
|       this.logger.info('Using external Port80Handler, skipping initialization'); | ||||
|       return this.port80Handler; | ||||
|     } | ||||
|      | ||||
|     if (!this.options.acme?.enabled) { | ||||
|       return null; | ||||
|     } | ||||
|      | ||||
|     // Build and configure Port80Handler | ||||
|     this.port80Handler = buildPort80Handler({ | ||||
|       port: this.options.acme.port, | ||||
|       accountEmail: this.options.acme.accountEmail, | ||||
|       useProduction: this.options.acme.useProduction, | ||||
|       httpsRedirectPort: this.options.port, // Redirect to our HTTPS port | ||||
|       enabled: this.options.acme.enabled, | ||||
|       certificateStore: this.options.acme.certificateStore, | ||||
|       skipConfiguredCerts: this.options.acme.skipConfiguredCerts | ||||
|     }); | ||||
|     // Subscribe to Port80Handler events | ||||
|     subscribeToPort80Handler(this.port80Handler, { | ||||
|       onCertificateIssued: this.handleCertificateIssued.bind(this), | ||||
|       onCertificateRenewed: this.handleCertificateIssued.bind(this), | ||||
|       onCertificateFailed: this.handleCertificateFailed.bind(this), | ||||
|       onCertificateExpiring: (data) => { | ||||
|         this.logger.info(`Certificate for ${data.domain} expires in ${data.daysRemaining} days`); | ||||
|       } | ||||
|     }); | ||||
|      | ||||
|     // Start the handler | ||||
|     try { | ||||
|       await this.port80Handler.start(); | ||||
|       this.logger.info(`Port80Handler started on port ${this.options.acme.port}`); | ||||
|       return this.port80Handler; | ||||
|     } catch (error) { | ||||
|       this.logger.error(`Failed to start Port80Handler: ${error}`); | ||||
|       this.port80Handler = null; | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Stop the Port80Handler if it was internally created | ||||
|    */ | ||||
|   public async stopPort80Handler(): Promise<void> { | ||||
|     if (this.port80Handler && !this.externalPort80Handler) { | ||||
|       try { | ||||
|         await this.port80Handler.stop(); | ||||
|         this.logger.info('Port80Handler stopped'); | ||||
|       } catch (error) { | ||||
|         this.logger.error('Error stopping Port80Handler', error); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -1,5 +1,17 @@ | ||||
| import * as plugins from '../../../plugins.js'; | ||||
| import type { IAcmeOptions } from '../../../certificate/models/certificate-types.js'; | ||||
| // Certificate types removed - define IAcmeOptions locally | ||||
| export interface IAcmeOptions { | ||||
|   enabled: boolean; | ||||
|   email?: string; | ||||
|   accountEmail?: string; | ||||
|   port?: number; | ||||
|   certificateStore?: string; | ||||
|   environment?: 'production' | 'staging'; | ||||
|   useProduction?: boolean; | ||||
|   renewThresholdDays?: number; | ||||
|   autoRenew?: boolean; | ||||
|   skipConfiguredCerts?: boolean; | ||||
| } | ||||
| import type { IRouteConfig } from '../../smart-proxy/models/route-types.js'; | ||||
| import type { IRouteContext } from '../../../core/models/route-context.js'; | ||||
|  | ||||
| @@ -22,7 +34,7 @@ export interface INetworkProxyOptions { | ||||
|   // Settings for SmartProxy integration | ||||
|   connectionPoolSize?: number; // Maximum connections to maintain in the pool to each backend | ||||
|   portProxyIntegration?: boolean; // Flag to indicate this proxy is used by SmartProxy | ||||
|   useExternalPort80Handler?: boolean; // Flag to indicate using external Port80Handler | ||||
|   useExternalPort80Handler?: boolean; // @deprecated - use SmartCertManager instead | ||||
|   // Protocol to use when proxying to backends: HTTP/1.x or HTTP/2 | ||||
|   backendProtocol?: 'http1' | 'http2'; | ||||
|  | ||||
|   | ||||
| @@ -18,7 +18,6 @@ import { RequestHandler, type IMetricsTracker } from './request-handler.js'; | ||||
| import { WebSocketHandler } from './websocket-handler.js'; | ||||
| import { ProxyRouter } from '../../http/router/index.js'; | ||||
| import { RouteRouter } from '../../http/router/route-router.js'; | ||||
| import { Port80Handler } from '../../http/port80/port80-handler.js'; | ||||
| import { FunctionCache } from './function-cache.js'; | ||||
|  | ||||
| /** | ||||
| @@ -221,15 +220,10 @@ export class NetworkProxy implements IMetricsTracker { | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Sets an external Port80Handler for certificate management | ||||
|    * This allows the NetworkProxy to use a centrally managed Port80Handler | ||||
|    * instead of creating its own | ||||
|    *  | ||||
|    * @param handler The Port80Handler instance to use | ||||
|    * @deprecated Use SmartCertManager instead | ||||
|    */ | ||||
|   public setExternalPort80Handler(handler: Port80Handler): void { | ||||
|     // Connect it to the certificate manager | ||||
|     this.certificateManager.setExternalPort80Handler(handler); | ||||
|   public setExternalPort80Handler(handler: any): void { | ||||
|     this.logger.warn('Port80Handler is deprecated - use SmartCertManager instead'); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
| @@ -238,10 +232,7 @@ export class NetworkProxy implements IMetricsTracker { | ||||
|   public async start(): Promise<void> { | ||||
|     this.startTime = Date.now(); | ||||
|      | ||||
|     // Initialize Port80Handler if enabled and not using external handler | ||||
|     if (this.options.acme?.enabled && !this.options.useExternalPort80Handler) { | ||||
|       await this.certificateManager.initializePort80Handler(); | ||||
|     } | ||||
|     // Certificate management is now handled by SmartCertManager | ||||
|      | ||||
|     // Create HTTP/2 server with HTTP/1 fallback | ||||
|     this.httpsServer = plugins.http2.createSecureServer( | ||||
| @@ -385,7 +376,7 @@ export class NetworkProxy implements IMetricsTracker { | ||||
|  | ||||
|     // Directly update the certificate manager with the new routes | ||||
|     // This will extract domains and handle certificate provisioning | ||||
|     this.certificateManager.updateRouteConfigs(routes); | ||||
|     this.certificateManager.updateRoutes(routes); | ||||
|  | ||||
|     // Collect all domains and certificates for configuration | ||||
|     const currentHostnames = new Set<string>(); | ||||
| @@ -425,7 +416,7 @@ export class NetworkProxy implements IMetricsTracker { | ||||
|     // Update certificate cache with any static certificates | ||||
|     for (const [domain, certData] of certificateUpdates.entries()) { | ||||
|       try { | ||||
|         this.certificateManager.updateCertificateCache( | ||||
|         this.certificateManager.updateCertificate( | ||||
|           domain, | ||||
|           certData.cert, | ||||
|           certData.key | ||||
| @@ -547,8 +538,7 @@ export class NetworkProxy implements IMetricsTracker { | ||||
|     // Close all connection pool connections | ||||
|     this.connectionPool.closeAllConnections(); | ||||
|      | ||||
|     // Stop Port80Handler if internally managed | ||||
|     await this.certificateManager.stopPort80Handler(); | ||||
|     // Certificate management cleanup is handled by SmartCertManager | ||||
|      | ||||
|     // Close the HTTPS server | ||||
|     return new Promise((resolve) => { | ||||
| @@ -566,7 +556,8 @@ export class NetworkProxy implements IMetricsTracker { | ||||
|    * @returns A promise that resolves when the request is submitted (not when the certificate is issued) | ||||
|    */ | ||||
|   public async requestCertificate(domain: string): Promise<boolean> { | ||||
|     return this.certificateManager.requestCertificate(domain); | ||||
|     this.logger.warn('requestCertificate is deprecated - use SmartCertManager instead'); | ||||
|     return false; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
| @@ -587,7 +578,7 @@ export class NetworkProxy implements IMetricsTracker { | ||||
|     expiryDate?: Date | ||||
|   ): void { | ||||
|     this.logger.info(`Updating certificate for ${domain}`); | ||||
|     this.certificateManager.updateCertificateCache(domain, certificate, privateKey, expiryDate); | ||||
|     this.certificateManager.updateCertificate(domain, certificate, privateKey); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| /** | ||||
|  * SmartProxy models | ||||
|  */ | ||||
| export * from './interfaces.js'; | ||||
| // Export everything except IAcmeOptions from interfaces | ||||
| export type { ISmartProxyOptions, IConnectionRecord, TSmartProxyCertProvisionObject } from './interfaces.js'; | ||||
| export * from './route-types.js'; | ||||
|   | ||||
| @@ -1,5 +1,18 @@ | ||||
| import * as plugins from '../../../plugins.js'; | ||||
| import type { IAcmeOptions } from '../../../certificate/models/certificate-types.js'; | ||||
| // Certificate types removed - define IAcmeOptions locally | ||||
| export interface IAcmeOptions { | ||||
|   enabled?: boolean; | ||||
|   email?: string; | ||||
|   environment?: 'production' | 'staging'; | ||||
|   port?: number; | ||||
|   useProduction?: boolean; | ||||
|   renewThresholdDays?: number; | ||||
|   autoRenew?: boolean; | ||||
|   certificateStore?: string; | ||||
|   skipConfiguredCerts?: boolean; | ||||
|   renewCheckIntervalHours?: number; | ||||
|   routeForwards?: any[]; | ||||
| } | ||||
| import type { IRouteConfig } from './route-types.js'; | ||||
| import type { TForwardingType } from '../../../forwarding/config/forwarding-types.js'; | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import * as plugins from '../../../plugins.js'; | ||||
| import type { IAcmeOptions } from '../../../certificate/models/certificate-types.js'; | ||||
| // Certificate types removed - use local definition | ||||
| import type { TForwardingType } from '../../../forwarding/config/forwarding-types.js'; | ||||
| import type { PortRange } from '../../../proxies/nftables-proxy/models/interfaces.js'; | ||||
|  | ||||
|   | ||||
| @@ -121,13 +121,12 @@ export class SmartProxy extends plugins.EventEmitter { | ||||
|       this.settings.acme = { | ||||
|         enabled: false, | ||||
|         port: 80, | ||||
|         accountEmail: 'admin@example.com', | ||||
|         email: 'admin@example.com', | ||||
|         useProduction: false, | ||||
|         renewThresholdDays: 30, | ||||
|         autoRenew: true, | ||||
|         certificateStore: './certs', | ||||
|         skipConfiguredCerts: false, | ||||
|         httpsRedirectPort: 443, | ||||
|         renewCheckIntervalHours: 24, | ||||
|         routeForwards: [] | ||||
|       }; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user