update
This commit is contained in:
		
							
								
								
									
										393
									
								
								readme.plan.md
									
									
									
									
									
								
							
							
						
						
									
										393
									
								
								readme.plan.md
									
									
									
									
									
								
							| @@ -1,281 +1,154 @@ | ||||
| # SmartProxy Implementation Plan | ||||
| # SmartProxy Enhanced Routing Plan | ||||
|  | ||||
| ## Feature: Custom Certificate Provision Function | ||||
| ## Goal | ||||
| Implement enhanced routing structure with multiple targets per route, sub-matching capabilities, and target-specific overrides to enable more elegant and DRY configurations. | ||||
|  | ||||
| ### Summary | ||||
| This plan implements the `certProvisionFunction` feature that allows users to provide their own certificate generation logic. The function can either return a custom certificate or delegate back to Let's Encrypt by returning 'http01'. | ||||
| ## Key Changes | ||||
|  | ||||
| ### Key Changes | ||||
| 1. Add `certProvisionFunction` support to CertificateManager | ||||
| 2. Modify `provisionAcmeCertificate()` to check custom function first | ||||
| 3. Add certificate expiry parsing for custom certificates | ||||
| 4. Support both initial provisioning and renewal | ||||
| 5. Add fallback configuration option | ||||
| ### 1. Update Route Target Interface | ||||
| - Add `match` property to `IRouteTarget` for sub-matching within routes | ||||
| - Add target-specific override properties (tls, websocket, loadBalancing, etc.) | ||||
| - Add priority field for controlling match order | ||||
|  | ||||
| ### Overview | ||||
| Implement the `certProvisionFunction` callback that's defined in the interface but currently not implemented. This will allow users to provide custom certificate generation logic while maintaining backward compatibility with the existing Let's Encrypt integration. | ||||
| ### 2. Update Route Action Interface | ||||
| - Remove singular `target` property | ||||
| - Use only `targets` array (single target = array with one element) | ||||
| - Maintain backwards compatibility during migration | ||||
|  | ||||
| ### Requirements | ||||
| 1. The function should be called for any new certificate provisioning or renewal | ||||
| 2. Must support returning custom certificates or falling back to Let's Encrypt | ||||
| 3. Should integrate seamlessly with the existing certificate lifecycle | ||||
| 4. Must maintain backward compatibility | ||||
| ### 3. Implementation Steps | ||||
|  | ||||
| ### Implementation Steps | ||||
| #### Phase 1: Type Updates | ||||
| - [ ] Update `IRouteTarget` interface in `route-types.ts` | ||||
|   - Add `match?: ITargetMatch` property | ||||
|   - Add override properties (tls, websocket, etc.) | ||||
|   - Add `priority?: number` field | ||||
| - [ ] Create `ITargetMatch` interface for sub-matching criteria | ||||
| - [ ] Update `IRouteAction` to use only `targets: IRouteTarget[]` | ||||
|  | ||||
| #### 1. Update Certificate Manager to Support Custom Provision Function | ||||
| **File**: `ts/proxies/smart-proxy/certificate-manager.ts` | ||||
| #### Phase 2: Route Resolution Logic | ||||
| - [ ] Update route matching logic to handle multiple targets | ||||
| - [ ] Implement target sub-matching algorithm: | ||||
|   1. Sort targets by priority (highest first) | ||||
|   2. For each target with a match property, check if request matches | ||||
|   3. Use first matching target, or fallback to target without match | ||||
| - [ ] Ensure target-specific settings override route-level settings | ||||
|  | ||||
| - [ ] Add `certProvisionFunction` property to CertificateManager class | ||||
| - [ ] Pass the function from SmartProxy options during initialization | ||||
| - [ ] Modify `provisionCertificate()` method to check for custom function first | ||||
| #### Phase 3: Code Migration | ||||
| - [ ] Find all occurrences of `action.target` and update to `action.targets[0]` | ||||
| - [ ] Update route helpers and utilities | ||||
| - [ ] Update certificate manager to handle multiple targets | ||||
| - [ ] Update connection handlers | ||||
|  | ||||
| #### 2. Implement Custom Certificate Provisioning Logic | ||||
| **Location**: Modify `provisionAcmeCertificate()` method | ||||
| #### Phase 4: Testing | ||||
| - [ ] Update existing tests to use new format | ||||
| - [ ] Add tests for multi-target scenarios | ||||
| - [ ] Add tests for sub-matching logic | ||||
| - [ ] Add tests for setting overrides | ||||
|  | ||||
| #### Phase 5: Documentation | ||||
| - [ ] Update type documentation | ||||
| - [ ] Add examples of new routing patterns | ||||
| - [ ] Document migration path for existing configs | ||||
|  | ||||
| ## Example Configurations | ||||
|  | ||||
| ### Before (Current) | ||||
| ```typescript | ||||
| private async provisionAcmeCertificate( | ||||
|   route: IRouteConfig,  | ||||
|   domains: string[] | ||||
| ): Promise<void> { | ||||
|   const primaryDomain = domains[0]; | ||||
|   const routeName = route.name || primaryDomain; | ||||
|    | ||||
|   // Check for custom provision function first | ||||
|   if (this.certProvisionFunction) { | ||||
|     try { | ||||
|       logger.log('info', `Attempting custom certificate provision for ${primaryDomain}`, { domain: primaryDomain }); | ||||
|       const result = await this.certProvisionFunction(primaryDomain); | ||||
|        | ||||
|       if (result === 'http01') { | ||||
|         logger.log('info', `Custom function returned 'http01', falling back to Let's Encrypt for ${primaryDomain}`); | ||||
|         // Continue with existing ACME logic below | ||||
|       } else { | ||||
|         // Use custom certificate | ||||
|         const customCert = result as plugins.tsclass.network.ICert; | ||||
|          | ||||
|         // Convert to internal certificate format | ||||
|         const certData: ICertificateData = { | ||||
|           cert: customCert.cert, | ||||
|           key: customCert.key, | ||||
|           ca: customCert.ca || '', | ||||
|           issueDate: new Date(), | ||||
|           expiryDate: this.extractExpiryDate(customCert.cert) | ||||
|         }; | ||||
|          | ||||
|         // Store and apply certificate | ||||
|         await this.certStore.saveCertificate(routeName, certData); | ||||
|         await this.applyCertificate(primaryDomain, certData); | ||||
|         this.updateCertStatus(routeName, 'valid', 'custom', certData); | ||||
|          | ||||
|         logger.log('info', `Custom certificate applied for ${primaryDomain}`, {  | ||||
|           domain: primaryDomain, | ||||
|           expiryDate: certData.expiryDate | ||||
|         }); | ||||
|         return; | ||||
| // Need separate routes for different ports/paths | ||||
| [ | ||||
|   { | ||||
|     match: { domains: ['api.example.com'], ports: [80] }, | ||||
|     action: { | ||||
|       type: 'forward', | ||||
|       target: { host: 'backend', port: 8080 }, | ||||
|       tls: { mode: 'terminate' } | ||||
|     } | ||||
|     } catch (error) { | ||||
|       logger.log('error', `Custom cert provision failed for ${primaryDomain}: ${error.message}`, { | ||||
|         domain: primaryDomain, | ||||
|         error: error.message | ||||
|       }); | ||||
|       // Configuration option to control fallback behavior | ||||
|       if (this.smartProxy.settings.certProvisionFallbackToAcme !== false) { | ||||
|         logger.log('info', `Falling back to Let's Encrypt for ${primaryDomain}`); | ||||
|       } else { | ||||
|         throw error; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   // Existing Let's Encrypt logic continues here... | ||||
|   if (!this.smartAcme) { | ||||
|     throw new Error('SmartAcme not initialized...'); | ||||
|   } | ||||
|   // ... rest of existing code | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### 3. Add Helper Method for Certificate Expiry Extraction | ||||
| **New method**: `extractExpiryDate()` | ||||
|  | ||||
| - [ ] Parse PEM certificate to extract expiry date | ||||
| - [ ] Use existing certificate parsing utilities | ||||
| - [ ] Handle parse errors gracefully | ||||
|  | ||||
| ```typescript | ||||
| private extractExpiryDate(certPem: string): Date { | ||||
|   try { | ||||
|     // Use forge or similar library to parse certificate | ||||
|     const cert = forge.pki.certificateFromPem(certPem); | ||||
|     return cert.validity.notAfter; | ||||
|   } catch (error) { | ||||
|     // Default to 90 days if parsing fails | ||||
|     return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### 4. Update SmartProxy Initialization | ||||
| **File**: `ts/proxies/smart-proxy/index.ts` | ||||
|  | ||||
| - [ ] Pass `certProvisionFunction` from options to CertificateManager | ||||
| - [ ] Validate function if provided | ||||
|  | ||||
| #### 5. Add Type Safety and Validation | ||||
| **Tasks**: | ||||
| - [ ] Validate returned certificate has required fields (cert, key, ca) | ||||
| - [ ] Check certificate validity dates | ||||
| - [ ] Ensure certificate matches requested domain | ||||
|  | ||||
| #### 6. Update Certificate Renewal Logic | ||||
| **Location**: `checkAndRenewCertificates()` | ||||
|  | ||||
| - [ ] Ensure renewal checks work for both ACME and custom certificates | ||||
| - [ ] Custom certificates should go through the same `provisionAcmeCertificate()` path | ||||
| - [ ] The existing renewal logic already calls `provisionCertificate()` which will use our modified flow | ||||
|  | ||||
| ```typescript | ||||
| // No changes needed here - the existing renewal logic will automatically | ||||
| // use the custom provision function when calling provisionCertificate() | ||||
| private async checkAndRenewCertificates(): Promise<void> { | ||||
|   // Existing code already handles this correctly | ||||
|   for (const route of routes) { | ||||
|     if (this.shouldRenewCertificate(cert, renewThreshold)) { | ||||
|       // This will call provisionCertificate -> provisionAcmeCertificate | ||||
|       // which now includes our custom function check | ||||
|       await this.provisionCertificate(route); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### 7. Add Integration Tests | ||||
| **File**: `test/test.certificate-provision.ts` | ||||
|  | ||||
| - [ ] Test custom certificate provision | ||||
| - [ ] Test fallback to Let's Encrypt ('http01' return) | ||||
| - [ ] Test error handling | ||||
| - [ ] Test renewal with custom function | ||||
|  | ||||
| #### 8. Update Documentation | ||||
| **Files**:  | ||||
| - [ ] Update interface documentation | ||||
| - [ ] Add examples to README | ||||
| - [ ] Document ICert structure requirements | ||||
|  | ||||
| ### API Design | ||||
|  | ||||
| ```typescript | ||||
| // Example usage | ||||
| const proxy = new SmartProxy({ | ||||
|   certProvisionFunction: async (domain: string) => { | ||||
|     // Option 1: Return custom certificate | ||||
|     const customCert = await myCustomCA.generateCert(domain); | ||||
|     return { | ||||
|       cert: customCert.certificate, | ||||
|       key: customCert.privateKey, | ||||
|       ca: customCert.chain | ||||
|     }; | ||||
|      | ||||
|     // Option 2: Use Let's Encrypt for certain domains | ||||
|     if (domain.endsWith('.internal')) { | ||||
|       return customCert; | ||||
|     } | ||||
|     return 'http01'; // Fallback to Let's Encrypt | ||||
|   }, | ||||
|   certProvisionFallbackToAcme: true, // Default: true | ||||
|   routes: [...] | ||||
| }); | ||||
|   { | ||||
|     match: { domains: ['api.example.com'], ports: [443] }, | ||||
|     action: { | ||||
|       type: 'forward', | ||||
|       target: { host: 'backend', port: 8081 }, | ||||
|       tls: { mode: 'passthrough' } | ||||
|     } | ||||
|   } | ||||
| ] | ||||
| ``` | ||||
|  | ||||
| ### Configuration Options to Add | ||||
|  | ||||
| ### After (Enhanced) | ||||
| ```typescript | ||||
| interface ISmartProxyOptions { | ||||
|   // Existing options... | ||||
|    | ||||
|   // Custom certificate provision function | ||||
|   certProvisionFunction?: (domain: string) => Promise<TSmartProxyCertProvisionObject>; | ||||
|    | ||||
|   // Whether to fallback to ACME if custom provision fails | ||||
|   certProvisionFallbackToAcme?: boolean; // Default: true | ||||
| // Single route with multiple targets | ||||
| { | ||||
|   match: { domains: ['api.example.com'], ports: [80, 443] }, | ||||
|   action: { | ||||
|     type: 'forward', | ||||
|     targets: [ | ||||
|       { | ||||
|         match: { ports: [80] }, | ||||
|         host: 'backend', | ||||
|         port: 8080, | ||||
|         tls: { mode: 'terminate' } | ||||
|       }, | ||||
|       { | ||||
|         match: { ports: [443] }, | ||||
|         host: 'backend', | ||||
|         port: 8081, | ||||
|         tls: { mode: 'passthrough' } | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### Error Handling Strategy | ||||
| ### Advanced Example | ||||
| ```typescript | ||||
| { | ||||
|   match: { domains: ['app.example.com'], ports: [443] }, | ||||
|   action: { | ||||
|     type: 'forward', | ||||
|     tls: { mode: 'terminate', certificate: 'auto' }, // Route-level default | ||||
|     websocket: { enabled: true }, // Route-level default | ||||
|     targets: [ | ||||
|       { | ||||
|         match: { path: '/api/v2/*' }, | ||||
|         host: 'api-v2', | ||||
|         port: 8082, | ||||
|         priority: 10 | ||||
|       }, | ||||
|       { | ||||
|         match: { path: '/api/*', headers: { 'X-Version': 'v1' } }, | ||||
|         host: 'api-v1', | ||||
|         port: 8081, | ||||
|         priority: 5 | ||||
|       }, | ||||
|       { | ||||
|         match: { path: '/ws/*' }, | ||||
|         host: 'websocket-server', | ||||
|         port: 8090, | ||||
|         websocket: {  | ||||
|           enabled: true, | ||||
|           rewritePath: '/'  // Strip /ws prefix | ||||
|         } | ||||
|       }, | ||||
|       { | ||||
|         // Default target (no match property) | ||||
|         host: 'web-backend', | ||||
|         port: 8080 | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| 1. **Custom Function Errors**: | ||||
|    - Log detailed error with domain context | ||||
|    - Option A: Fallback to Let's Encrypt (safer) | ||||
|    - Option B: Fail certificate provisioning (stricter) | ||||
|    - Make this configurable via option? | ||||
| ## Benefits | ||||
| 1. **DRY Configuration**: No need to duplicate common settings across routes | ||||
| 2. **Flexibility**: Different backends for different ports/paths within same domain | ||||
| 3. **Clarity**: All routing for a domain in one place | ||||
| 4. **Performance**: Single route lookup instead of multiple | ||||
| 5. **Backwards Compatible**: Can migrate gradually | ||||
|  | ||||
| 2. **Invalid Certificate Returns**: | ||||
|    - Validate certificate structure | ||||
|    - Check expiry dates | ||||
|    - Verify domain match | ||||
|  | ||||
| ### Testing Plan | ||||
|  | ||||
| 1. **Unit Tests**: | ||||
|    - Mock certProvisionFunction returns | ||||
|    - Test validation logic | ||||
|    - Test error scenarios | ||||
|  | ||||
| 2. **Integration Tests**: | ||||
|    - Real certificate generation | ||||
|    - Renewal cycle testing | ||||
|    - Mixed custom/Let's Encrypt scenarios | ||||
|  | ||||
| ### Backward Compatibility | ||||
|  | ||||
| - If no `certProvisionFunction` provided, behavior unchanged | ||||
| - Existing routes with 'auto' certificates continue using Let's Encrypt | ||||
| - No breaking changes to existing API | ||||
|  | ||||
| ### Future Enhancements | ||||
|  | ||||
| 1. **Per-Route Custom Functions**: | ||||
|    - Allow different provision functions per route | ||||
|    - Override global function at route level | ||||
|  | ||||
| 2. **Certificate Events**: | ||||
|    - Emit events for custom cert provisioning | ||||
|    - Allow monitoring/logging hooks | ||||
|  | ||||
| 3. **Async Certificate Updates**: | ||||
|    - Support updating certificates outside renewal cycle | ||||
|    - Hot-reload certificates without restart | ||||
|  | ||||
| ### Implementation Notes | ||||
|  | ||||
| 1. **Certificate Status Tracking**: | ||||
|    - The `updateCertStatus()` method needs to support a new type: 'custom' | ||||
|    - Current types are 'acme' and 'static' | ||||
|    - This helps distinguish custom certificates in monitoring/logs | ||||
|  | ||||
| 2. **Certificate Store Integration**: | ||||
|    - Custom certificates are stored the same way as ACME certificates | ||||
|    - They participate in the same renewal cycle | ||||
|    - The store handles persistence across restarts | ||||
|  | ||||
| 3. **Existing Methods to Reuse**: | ||||
|    - `applyCertificate()` - Already handles applying certs to routes | ||||
|    - `isCertificateValid()` - Can validate custom certificates | ||||
|    - `certStore.saveCertificate()` - Handles storage | ||||
|  | ||||
| ### Implementation Priority | ||||
|  | ||||
| 1. Core functionality (steps 1-3) | ||||
| 2. Type safety and validation (step 5) | ||||
| 3. Renewal support (step 6) | ||||
| 4. Tests (step 7) | ||||
| 5. Documentation (step 8) | ||||
|  | ||||
| ### Estimated Effort | ||||
|  | ||||
| - Core implementation: 4-6 hours | ||||
| - Testing: 2-3 hours | ||||
| - Documentation: 1 hour | ||||
| - Total: ~8-10 hours | ||||
| ## Migration Strategy | ||||
| 1. Keep support for `target` temporarily with deprecation warning | ||||
| 2. Auto-convert `target` to `targets: [target]` internally | ||||
| 3. Update documentation with migration examples | ||||
| 4. Remove `target` support in next major version | ||||
| @@ -209,10 +209,10 @@ tap.test('SmartProxy: Should create instance with route-based config', async () | ||||
|       }) | ||||
|     ], | ||||
|     defaults: { | ||||
|       target: { | ||||
|       targets: [{ | ||||
|         host: 'localhost', | ||||
|         port: 8080 | ||||
|       }, | ||||
|       }], | ||||
|       security: { | ||||
|         ipAllowList: ['127.0.0.1', '192.168.0.*'], | ||||
|         maxConnections: 100 | ||||
| @@ -333,10 +333,10 @@ tap.test('Edge Case - Complex Path and Headers Matching', async () => { | ||||
|     }, | ||||
|     action: { | ||||
|       type: 'forward', | ||||
|       target: { | ||||
|       targets: [{ | ||||
|         host: 'internal-api', | ||||
|         port: 8080 | ||||
|       }, | ||||
|       }], | ||||
|       tls: { | ||||
|         mode: 'terminate', | ||||
|         certificate: 'auto' | ||||
| @@ -376,10 +376,10 @@ tap.test('Edge Case - Port Range Matching', async () => { | ||||
|     }, | ||||
|     action: { | ||||
|       type: 'forward', | ||||
|       target: { | ||||
|       targets: [{ | ||||
|         host: 'backend', | ||||
|         port: 3000 | ||||
|       } | ||||
|       }] | ||||
|     }, | ||||
|     name: 'Port Range Route' | ||||
|   }; | ||||
| @@ -404,10 +404,10 @@ tap.test('Edge Case - Port Range Matching', async () => { | ||||
|     }, | ||||
|     action: { | ||||
|       type: 'forward', | ||||
|       target: { | ||||
|       targets: [{ | ||||
|         host: 'backend', | ||||
|         port: 3000 | ||||
|       } | ||||
|       }] | ||||
|     }, | ||||
|     name: 'Multi Range Route' | ||||
|   }; | ||||
|   | ||||
| @@ -10,7 +10,7 @@ import { ConnectionPool } from './connection-pool.js'; | ||||
| import { ContextCreator } from './context-creator.js'; | ||||
| import { HttpRequestHandler } from './http-request-handler.js'; | ||||
| import { Http2RequestHandler } from './http2-request-handler.js'; | ||||
| import type { IRouteConfig } from '../smart-proxy/models/route-types.js'; | ||||
| import type { IRouteConfig, IRouteTarget } from '../smart-proxy/models/route-types.js'; | ||||
| import type { IRouteContext, IHttpRouteContext } from '../../core/models/route-context.js'; | ||||
| import { toBaseContext } from '../../core/models/route-context.js'; | ||||
| import { TemplateUtils } from '../../core/utils/template-utils.js'; | ||||
| @@ -99,6 +99,80 @@ export class RequestHandler { | ||||
|     return { ...this.defaultHeaders }; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Select the appropriate target from the targets array based on sub-matching criteria | ||||
|    */ | ||||
|   private selectTarget( | ||||
|     targets: IRouteTarget[], | ||||
|     context: { | ||||
|       port: number; | ||||
|       path?: string; | ||||
|       headers?: Record<string, string>; | ||||
|       method?: string; | ||||
|     } | ||||
|   ): IRouteTarget | null { | ||||
|     // Sort targets by priority (higher first) | ||||
|     const sortedTargets = [...targets].sort((a, b) => (b.priority || 0) - (a.priority || 0)); | ||||
|      | ||||
|     // Find the first matching target | ||||
|     for (const target of sortedTargets) { | ||||
|       if (!target.match) { | ||||
|         // No match criteria means this is a default/fallback target | ||||
|         return target; | ||||
|       } | ||||
|        | ||||
|       // Check port match | ||||
|       if (target.match.ports && !target.match.ports.includes(context.port)) { | ||||
|         continue; | ||||
|       } | ||||
|        | ||||
|       // Check path match (supports wildcards) | ||||
|       if (target.match.path && context.path) { | ||||
|         const pathPattern = target.match.path.replace(/\*/g, '.*'); | ||||
|         const pathRegex = new RegExp(`^${pathPattern}$`); | ||||
|         if (!pathRegex.test(context.path)) { | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // Check method match | ||||
|       if (target.match.method && context.method && !target.match.method.includes(context.method)) { | ||||
|         continue; | ||||
|       } | ||||
|        | ||||
|       // Check headers match | ||||
|       if (target.match.headers && context.headers) { | ||||
|         let headersMatch = true; | ||||
|         for (const [key, pattern] of Object.entries(target.match.headers)) { | ||||
|           const headerValue = context.headers[key.toLowerCase()]; | ||||
|           if (!headerValue) { | ||||
|             headersMatch = false; | ||||
|             break; | ||||
|           } | ||||
|            | ||||
|           if (pattern instanceof RegExp) { | ||||
|             if (!pattern.test(headerValue)) { | ||||
|               headersMatch = false; | ||||
|               break; | ||||
|             } | ||||
|           } else if (headerValue !== pattern) { | ||||
|             headersMatch = false; | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|         if (!headersMatch) { | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // All criteria matched | ||||
|       return target; | ||||
|     } | ||||
|      | ||||
|     // No matching target found, return the first target without match criteria (default) | ||||
|     return sortedTargets.find(t => !t.match) || null; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Apply CORS headers to response if configured | ||||
|    * Implements Phase 5.5: Context-aware CORS handling | ||||
| @@ -480,17 +554,31 @@ export class RequestHandler { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // If we found a matching route with function-based targets, use it | ||||
|     if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.target) { | ||||
|     // If we found a matching route with forward action, select appropriate target | ||||
|     if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.targets && matchingRoute.action.targets.length > 0) { | ||||
|       this.logger.debug(`Found matching route: ${matchingRoute.name || 'unnamed'}`); | ||||
|  | ||||
|       // Select the appropriate target from the targets array | ||||
|       const selectedTarget = this.selectTarget(matchingRoute.action.targets, { | ||||
|         port: routeContext.port, | ||||
|         path: routeContext.path, | ||||
|         headers: routeContext.headers, | ||||
|         method: routeContext.method | ||||
|       }); | ||||
|  | ||||
|       if (!selectedTarget) { | ||||
|         this.logger.error(`No matching target found for route ${matchingRoute.name}`); | ||||
|         req.socket.end(); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Extract target information, resolving functions if needed | ||||
|       let targetHost: string | string[]; | ||||
|       let targetPort: number; | ||||
|  | ||||
|       try { | ||||
|         // Check function cache for host and resolve or use cached value | ||||
|         if (typeof matchingRoute.action.target.host === 'function') { | ||||
|         if (typeof selectedTarget.host === 'function') { | ||||
|           // Generate a function ID for caching (use route name or ID if available) | ||||
|           const functionId = `host-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; | ||||
|  | ||||
| @@ -502,7 +590,7 @@ export class RequestHandler { | ||||
|               this.logger.debug(`Using cached host value for ${functionId}`); | ||||
|             } else { | ||||
|               // Resolve the function and cache the result | ||||
|               const resolvedHost = matchingRoute.action.target.host(toBaseContext(routeContext)); | ||||
|               const resolvedHost = selectedTarget.host(toBaseContext(routeContext)); | ||||
|               targetHost = resolvedHost; | ||||
|  | ||||
|               // Cache the result | ||||
| @@ -511,16 +599,16 @@ export class RequestHandler { | ||||
|             } | ||||
|           } else { | ||||
|             // No cache available, just resolve | ||||
|             const resolvedHost = matchingRoute.action.target.host(routeContext); | ||||
|             const resolvedHost = selectedTarget.host(routeContext); | ||||
|             targetHost = resolvedHost; | ||||
|             this.logger.debug(`Resolved function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); | ||||
|           } | ||||
|         } else { | ||||
|           targetHost = matchingRoute.action.target.host; | ||||
|           targetHost = selectedTarget.host; | ||||
|         } | ||||
|  | ||||
|         // Check function cache for port and resolve or use cached value | ||||
|         if (typeof matchingRoute.action.target.port === 'function') { | ||||
|         if (typeof selectedTarget.port === 'function') { | ||||
|           // Generate a function ID for caching | ||||
|           const functionId = `port-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; | ||||
|  | ||||
| @@ -532,7 +620,7 @@ export class RequestHandler { | ||||
|               this.logger.debug(`Using cached port value for ${functionId}`); | ||||
|             } else { | ||||
|               // Resolve the function and cache the result | ||||
|               const resolvedPort = matchingRoute.action.target.port(toBaseContext(routeContext)); | ||||
|               const resolvedPort = selectedTarget.port(toBaseContext(routeContext)); | ||||
|               targetPort = resolvedPort; | ||||
|  | ||||
|               // Cache the result | ||||
| @@ -541,12 +629,12 @@ export class RequestHandler { | ||||
|             } | ||||
|           } else { | ||||
|             // No cache available, just resolve | ||||
|             const resolvedPort = matchingRoute.action.target.port(routeContext); | ||||
|             const resolvedPort = selectedTarget.port(routeContext); | ||||
|             targetPort = resolvedPort; | ||||
|             this.logger.debug(`Resolved function-based port to: ${resolvedPort}`); | ||||
|           } | ||||
|         } else { | ||||
|           targetPort = matchingRoute.action.target.port === 'preserve' ? routeContext.port : matchingRoute.action.target.port as number; | ||||
|           targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number; | ||||
|         } | ||||
|  | ||||
|         // Select a single host if an array was provided | ||||
| @@ -626,17 +714,32 @@ export class RequestHandler { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // If we found a matching route with function-based targets, use it | ||||
|     if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.target) { | ||||
|     // If we found a matching route with forward action, select appropriate target | ||||
|     if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.targets && matchingRoute.action.targets.length > 0) { | ||||
|       this.logger.debug(`Found matching route for HTTP/2 request: ${matchingRoute.name || 'unnamed'}`); | ||||
|  | ||||
|       // Select the appropriate target from the targets array | ||||
|       const selectedTarget = this.selectTarget(matchingRoute.action.targets, { | ||||
|         port: routeContext.port, | ||||
|         path: routeContext.path, | ||||
|         headers: routeContext.headers, | ||||
|         method: routeContext.method | ||||
|       }); | ||||
|  | ||||
|       if (!selectedTarget) { | ||||
|         this.logger.error(`No matching target found for route ${matchingRoute.name}`); | ||||
|         stream.respond({ ':status': 502 }); | ||||
|         stream.end(); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Extract target information, resolving functions if needed | ||||
|       let targetHost: string | string[]; | ||||
|       let targetPort: number; | ||||
|  | ||||
|       try { | ||||
|         // Check function cache for host and resolve or use cached value | ||||
|         if (typeof matchingRoute.action.target.host === 'function') { | ||||
|         if (typeof selectedTarget.host === 'function') { | ||||
|           // Generate a function ID for caching (use route name or ID if available) | ||||
|           const functionId = `host-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; | ||||
|  | ||||
| @@ -648,7 +751,7 @@ export class RequestHandler { | ||||
|               this.logger.debug(`Using cached host value for HTTP/2: ${functionId}`); | ||||
|             } else { | ||||
|               // Resolve the function and cache the result | ||||
|               const resolvedHost = matchingRoute.action.target.host(toBaseContext(routeContext)); | ||||
|               const resolvedHost = selectedTarget.host(toBaseContext(routeContext)); | ||||
|               targetHost = resolvedHost; | ||||
|  | ||||
|               // Cache the result | ||||
| @@ -657,16 +760,16 @@ export class RequestHandler { | ||||
|             } | ||||
|           } else { | ||||
|             // No cache available, just resolve | ||||
|             const resolvedHost = matchingRoute.action.target.host(routeContext); | ||||
|             const resolvedHost = selectedTarget.host(routeContext); | ||||
|             targetHost = resolvedHost; | ||||
|             this.logger.debug(`Resolved HTTP/2 function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); | ||||
|           } | ||||
|         } else { | ||||
|           targetHost = matchingRoute.action.target.host; | ||||
|           targetHost = selectedTarget.host; | ||||
|         } | ||||
|  | ||||
|         // Check function cache for port and resolve or use cached value | ||||
|         if (typeof matchingRoute.action.target.port === 'function') { | ||||
|         if (typeof selectedTarget.port === 'function') { | ||||
|           // Generate a function ID for caching | ||||
|           const functionId = `port-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; | ||||
|  | ||||
| @@ -678,7 +781,7 @@ export class RequestHandler { | ||||
|               this.logger.debug(`Using cached port value for HTTP/2: ${functionId}`); | ||||
|             } else { | ||||
|               // Resolve the function and cache the result | ||||
|               const resolvedPort = matchingRoute.action.target.port(toBaseContext(routeContext)); | ||||
|               const resolvedPort = selectedTarget.port(toBaseContext(routeContext)); | ||||
|               targetPort = resolvedPort; | ||||
|  | ||||
|               // Cache the result | ||||
| @@ -687,12 +790,12 @@ export class RequestHandler { | ||||
|             } | ||||
|           } else { | ||||
|             // No cache available, just resolve | ||||
|             const resolvedPort = matchingRoute.action.target.port(routeContext); | ||||
|             const resolvedPort = selectedTarget.port(routeContext); | ||||
|             targetPort = resolvedPort; | ||||
|             this.logger.debug(`Resolved HTTP/2 function-based port to: ${resolvedPort}`); | ||||
|           } | ||||
|         } else { | ||||
|           targetPort = matchingRoute.action.target.port === 'preserve' ? routeContext.port : matchingRoute.action.target.port as number; | ||||
|           targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number; | ||||
|         } | ||||
|  | ||||
|         // Select a single host if an array was provided | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import '../../core/models/socket-augmentation.js'; | ||||
| import { type IHttpProxyOptions, type IWebSocketWithHeartbeat, type ILogger, createLogger } from './models/types.js'; | ||||
| import { ConnectionPool } from './connection-pool.js'; | ||||
| import { HttpRouter } from '../../routing/router/index.js'; | ||||
| import type { IRouteConfig } from '../smart-proxy/models/route-types.js'; | ||||
| import type { IRouteConfig, IRouteTarget } from '../smart-proxy/models/route-types.js'; | ||||
| import type { IRouteContext } from '../../core/models/route-context.js'; | ||||
| import { toBaseContext } from '../../core/models/route-context.js'; | ||||
| import { ContextCreator } from './context-creator.js'; | ||||
| @@ -53,6 +53,80 @@ export class WebSocketHandler { | ||||
|     this.securityManager.setRoutes(routes); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Select the appropriate target from the targets array based on sub-matching criteria | ||||
|    */ | ||||
|   private selectTarget( | ||||
|     targets: IRouteTarget[], | ||||
|     context: { | ||||
|       port: number; | ||||
|       path?: string; | ||||
|       headers?: Record<string, string>; | ||||
|       method?: string; | ||||
|     } | ||||
|   ): IRouteTarget | null { | ||||
|     // Sort targets by priority (higher first) | ||||
|     const sortedTargets = [...targets].sort((a, b) => (b.priority || 0) - (a.priority || 0)); | ||||
|      | ||||
|     // Find the first matching target | ||||
|     for (const target of sortedTargets) { | ||||
|       if (!target.match) { | ||||
|         // No match criteria means this is a default/fallback target | ||||
|         return target; | ||||
|       } | ||||
|        | ||||
|       // Check port match | ||||
|       if (target.match.ports && !target.match.ports.includes(context.port)) { | ||||
|         continue; | ||||
|       } | ||||
|        | ||||
|       // Check path match (supports wildcards) | ||||
|       if (target.match.path && context.path) { | ||||
|         const pathPattern = target.match.path.replace(/\*/g, '.*'); | ||||
|         const pathRegex = new RegExp(`^${pathPattern}$`); | ||||
|         if (!pathRegex.test(context.path)) { | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // Check method match | ||||
|       if (target.match.method && context.method && !target.match.method.includes(context.method)) { | ||||
|         continue; | ||||
|       } | ||||
|        | ||||
|       // Check headers match | ||||
|       if (target.match.headers && context.headers) { | ||||
|         let headersMatch = true; | ||||
|         for (const [key, pattern] of Object.entries(target.match.headers)) { | ||||
|           const headerValue = context.headers[key.toLowerCase()]; | ||||
|           if (!headerValue) { | ||||
|             headersMatch = false; | ||||
|             break; | ||||
|           } | ||||
|            | ||||
|           if (pattern instanceof RegExp) { | ||||
|             if (!pattern.test(headerValue)) { | ||||
|               headersMatch = false; | ||||
|               break; | ||||
|             } | ||||
|           } else if (headerValue !== pattern) { | ||||
|             headersMatch = false; | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|         if (!headersMatch) { | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // All criteria matched | ||||
|       return target; | ||||
|     } | ||||
|      | ||||
|     // No matching target found, return the first target without match criteria (default) | ||||
|     return sortedTargets.find(t => !t.match) || null; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Initialize WebSocket server on an existing HTTPS server | ||||
|    */ | ||||
| @@ -146,9 +220,23 @@ export class WebSocketHandler { | ||||
|       let destination: { host: string; port: number }; | ||||
|  | ||||
|       // If we found a route with the modern router, use it | ||||
|       if (route && route.action.type === 'forward' && route.action.target) { | ||||
|       if (route && route.action.type === 'forward' && route.action.targets && route.action.targets.length > 0) { | ||||
|         this.logger.debug(`Found matching WebSocket route: ${route.name || 'unnamed'}`); | ||||
|  | ||||
|         // Select the appropriate target from the targets array | ||||
|         const selectedTarget = this.selectTarget(route.action.targets, { | ||||
|           port: routeContext.port, | ||||
|           path: routeContext.path, | ||||
|           headers: routeContext.headers, | ||||
|           method: routeContext.method | ||||
|         }); | ||||
|  | ||||
|         if (!selectedTarget) { | ||||
|           this.logger.error(`No matching target found for route ${route.name}`); | ||||
|           wsIncoming.close(1003, 'No matching target'); | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         // Check if WebSockets are enabled for this route | ||||
|         if (route.action.websocket?.enabled === false) { | ||||
|           this.logger.debug(`WebSockets are disabled for route: ${route.name || 'unnamed'}`); | ||||
| @@ -192,20 +280,20 @@ export class WebSocketHandler { | ||||
|  | ||||
|         try { | ||||
|           // Resolve host if it's a function | ||||
|           if (typeof route.action.target.host === 'function') { | ||||
|             const resolvedHost = route.action.target.host(toBaseContext(routeContext)); | ||||
|           if (typeof selectedTarget.host === 'function') { | ||||
|             const resolvedHost = selectedTarget.host(toBaseContext(routeContext)); | ||||
|             targetHost = resolvedHost; | ||||
|             this.logger.debug(`Resolved function-based host for WebSocket: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); | ||||
|           } else { | ||||
|             targetHost = route.action.target.host; | ||||
|             targetHost = selectedTarget.host; | ||||
|           } | ||||
|  | ||||
|           // Resolve port if it's a function | ||||
|           if (typeof route.action.target.port === 'function') { | ||||
|             targetPort = route.action.target.port(toBaseContext(routeContext)); | ||||
|           if (typeof selectedTarget.port === 'function') { | ||||
|             targetPort = selectedTarget.port(toBaseContext(routeContext)); | ||||
|             this.logger.debug(`Resolved function-based port for WebSocket: ${targetPort}`); | ||||
|           } else { | ||||
|             targetPort = route.action.target.port === 'preserve' ? routeContext.port : route.action.target.port as number; | ||||
|             targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number; | ||||
|           } | ||||
|  | ||||
|           // Select a single host if an array was provided | ||||
|   | ||||
| @@ -46,11 +46,36 @@ export interface IRouteMatch { | ||||
|  | ||||
|  | ||||
| /** | ||||
|  * Target configuration for forwarding | ||||
|  * Target-specific match criteria for sub-routing within a route | ||||
|  */ | ||||
| export interface ITargetMatch { | ||||
|   ports?: number[];                             // Match specific ports from the route | ||||
|   path?: string;                                // Match specific paths (supports wildcards like /api/*) | ||||
|   headers?: Record<string, string | RegExp>;    // Match specific HTTP headers | ||||
|   method?: string[];                            // Match specific HTTP methods (GET, POST, etc.) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Target configuration for forwarding with sub-matching and overrides | ||||
|  */ | ||||
| export interface IRouteTarget { | ||||
|   // Optional sub-matching criteria within the route | ||||
|   match?: ITargetMatch; | ||||
|    | ||||
|   // Target destination | ||||
|   host: string | string[] | ((context: IRouteContext) => string | string[]);  // Host or hosts with optional function for dynamic resolution | ||||
|   port: number | 'preserve' | ((context: IRouteContext) => number);  // Port with optional function for dynamic mapping (use 'preserve' to keep the incoming port) | ||||
|    | ||||
|   // Optional target-specific overrides (these override route-level settings) | ||||
|   tls?: IRouteTls;                             // Override route-level TLS settings | ||||
|   websocket?: IRouteWebSocket;                 // Override route-level WebSocket settings | ||||
|   loadBalancing?: IRouteLoadBalancing;         // Override route-level load balancing | ||||
|   sendProxyProtocol?: boolean;                 // Override route-level proxy protocol setting | ||||
|   headers?: IRouteHeaders;                     // Override route-level headers | ||||
|   advanced?: IRouteAdvanced;                   // Override route-level advanced settings | ||||
|    | ||||
|   // Priority for matching (higher values are checked first, default: 0) | ||||
|   priority?: number; | ||||
| } | ||||
|  | ||||
| /** | ||||
| @@ -221,19 +246,19 @@ export interface IRouteAction { | ||||
|   // Basic routing | ||||
|   type: TRouteActionType; | ||||
|  | ||||
|   // Target for forwarding | ||||
|   target?: IRouteTarget; | ||||
|   // Targets for forwarding (array supports multiple targets with sub-matching) | ||||
|   targets: IRouteTarget[]; | ||||
|  | ||||
|   // TLS handling | ||||
|   // TLS handling (default for all targets, can be overridden per target) | ||||
|   tls?: IRouteTls; | ||||
|  | ||||
|   // WebSocket support | ||||
|   // WebSocket support (default for all targets, can be overridden per target) | ||||
|   websocket?: IRouteWebSocket; | ||||
|  | ||||
|   // Load balancing options | ||||
|   // Load balancing options (default for all targets, can be overridden per target) | ||||
|   loadBalancing?: IRouteLoadBalancing; | ||||
|  | ||||
|   // Advanced options | ||||
|   // Advanced options (default for all targets, can be overridden per target) | ||||
|   advanced?: IRouteAdvanced; | ||||
|    | ||||
|   // Additional options for backend-specific settings | ||||
| @@ -251,7 +276,7 @@ export interface IRouteAction { | ||||
|   // Socket handler function (when type is 'socket-handler') | ||||
|   socketHandler?: TSocketHandler; | ||||
|    | ||||
|   // PROXY protocol support | ||||
|   // PROXY protocol support (default for all targets, can be overridden per target) | ||||
|   sendProxyProtocol?: boolean; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -123,39 +123,43 @@ export class NFTablesManager { | ||||
|   private createNfTablesOptions(route: IRouteConfig): NfTableProxyOptions { | ||||
|     const { action } = route; | ||||
|      | ||||
|     // Ensure we have a target | ||||
|     if (!action.target) { | ||||
|       throw new Error('Route must have a target to use NFTables forwarding'); | ||||
|     // Ensure we have targets | ||||
|     if (!action.targets || action.targets.length === 0) { | ||||
|       throw new Error('Route must have targets to use NFTables forwarding'); | ||||
|     } | ||||
|      | ||||
|     // NFTables can only handle a single target, so we use the first target without match criteria | ||||
|     // or the first target if all have match criteria | ||||
|     const defaultTarget = action.targets.find(t => !t.match) || action.targets[0]; | ||||
|      | ||||
|     // Convert port specifications | ||||
|     const fromPorts = this.expandPortRange(route.match.ports); | ||||
|      | ||||
|     // Determine target port | ||||
|     let toPorts: number | PortRange | Array<number | PortRange>; | ||||
|      | ||||
|     if (action.target.port === 'preserve') { | ||||
|     if (defaultTarget.port === 'preserve') { | ||||
|       // 'preserve' means use the same ports as the source | ||||
|       toPorts = fromPorts; | ||||
|     } else if (typeof action.target.port === 'function') { | ||||
|     } else if (typeof defaultTarget.port === 'function') { | ||||
|       // For function-based ports, we can't determine at setup time | ||||
|       // Use the "preserve" approach and let NFTables handle it | ||||
|       toPorts = fromPorts; | ||||
|     } else { | ||||
|       toPorts = action.target.port; | ||||
|       toPorts = defaultTarget.port; | ||||
|     } | ||||
|      | ||||
|     // Determine target host | ||||
|     let toHost: string; | ||||
|     if (typeof action.target.host === 'function') { | ||||
|     if (typeof defaultTarget.host === 'function') { | ||||
|       // Can't determine at setup time, use localhost as a placeholder | ||||
|       // and rely on run-time handling | ||||
|       toHost = 'localhost'; | ||||
|     } else if (Array.isArray(action.target.host)) { | ||||
|     } else if (Array.isArray(defaultTarget.host)) { | ||||
|       // Use first host for now - NFTables will do simple round-robin   | ||||
|       toHost = action.target.host[0]; | ||||
|       toHost = defaultTarget.host[0]; | ||||
|     } else { | ||||
|       toHost = action.target.host; | ||||
|       toHost = defaultTarget.host; | ||||
|     } | ||||
|      | ||||
|     // Create options | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces. | ||||
| import { logger } from '../../core/utils/logger.js'; | ||||
| import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js'; | ||||
| // Route checking functions have been removed | ||||
| import type { IRouteConfig, IRouteAction } from './models/route-types.js'; | ||||
| import type { IRouteConfig, IRouteAction, IRouteTarget } from './models/route-types.js'; | ||||
| import type { IRouteContext } from '../../core/models/route-context.js'; | ||||
| import { cleanupSocket, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js'; | ||||
| import { WrappedSocket } from '../../core/models/wrapped-socket.js'; | ||||
| @@ -657,6 +657,80 @@ export class RouteConnectionHandler { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Select the appropriate target from the targets array based on sub-matching criteria | ||||
|    */ | ||||
|   private selectTarget( | ||||
|     targets: IRouteTarget[], | ||||
|     context: { | ||||
|       port: number; | ||||
|       path?: string; | ||||
|       headers?: Record<string, string>; | ||||
|       method?: string; | ||||
|     } | ||||
|   ): IRouteTarget | null { | ||||
|     // Sort targets by priority (higher first) | ||||
|     const sortedTargets = [...targets].sort((a, b) => (b.priority || 0) - (a.priority || 0)); | ||||
|      | ||||
|     // Find the first matching target | ||||
|     for (const target of sortedTargets) { | ||||
|       if (!target.match) { | ||||
|         // No match criteria means this is a default/fallback target | ||||
|         return target; | ||||
|       } | ||||
|        | ||||
|       // Check port match | ||||
|       if (target.match.ports && !target.match.ports.includes(context.port)) { | ||||
|         continue; | ||||
|       } | ||||
|        | ||||
|       // Check path match (supports wildcards) | ||||
|       if (target.match.path && context.path) { | ||||
|         const pathPattern = target.match.path.replace(/\*/g, '.*'); | ||||
|         const pathRegex = new RegExp(`^${pathPattern}$`); | ||||
|         if (!pathRegex.test(context.path)) { | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // Check method match | ||||
|       if (target.match.method && context.method && !target.match.method.includes(context.method)) { | ||||
|         continue; | ||||
|       } | ||||
|        | ||||
|       // Check headers match | ||||
|       if (target.match.headers && context.headers) { | ||||
|         let headersMatch = true; | ||||
|         for (const [key, pattern] of Object.entries(target.match.headers)) { | ||||
|           const headerValue = context.headers[key.toLowerCase()]; | ||||
|           if (!headerValue) { | ||||
|             headersMatch = false; | ||||
|             break; | ||||
|           } | ||||
|            | ||||
|           if (pattern instanceof RegExp) { | ||||
|             if (!pattern.test(headerValue)) { | ||||
|               headersMatch = false; | ||||
|               break; | ||||
|             } | ||||
|           } else if (headerValue !== pattern) { | ||||
|             headersMatch = false; | ||||
|             break; | ||||
|           } | ||||
|         } | ||||
|         if (!headersMatch) { | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       // All criteria matched | ||||
|       return target; | ||||
|     } | ||||
|      | ||||
|     // No matching target found, return the first target without match criteria (default) | ||||
|     return sortedTargets.find(t => !t.match) || null; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Handle a forward action for a route | ||||
|    */ | ||||
| @@ -731,14 +805,37 @@ export class RouteConnectionHandler { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // We should have a target configuration for forwarding | ||||
|     if (!action.target) { | ||||
|       logger.log('error', `Forward action missing target configuration for connection ${connectionId}`, { | ||||
|     // Select the appropriate target from the targets array | ||||
|     if (!action.targets || action.targets.length === 0) { | ||||
|       logger.log('error', `Forward action missing targets configuration for connection ${connectionId}`, { | ||||
|         connectionId, | ||||
|         component: 'route-handler' | ||||
|       }); | ||||
|       socket.end(); | ||||
|       this.smartProxy.connectionManager.cleanupConnection(record, 'missing_target'); | ||||
|       this.smartProxy.connectionManager.cleanupConnection(record, 'missing_targets'); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     // Create context for target selection | ||||
|     const targetSelectionContext = { | ||||
|       port: record.localPort, | ||||
|       path: undefined, // Will be populated from HTTP headers if available | ||||
|       headers: undefined, // Will be populated from HTTP headers if available | ||||
|       method: undefined // Will be populated from HTTP headers if available | ||||
|     }; | ||||
|      | ||||
|     // TODO: Extract path, headers, and method from initialChunk if it's HTTP | ||||
|     // For now, we'll select based on port only | ||||
|      | ||||
|     const selectedTarget = this.selectTarget(action.targets, targetSelectionContext); | ||||
|     if (!selectedTarget) { | ||||
|       logger.log('error', `No matching target found for connection ${connectionId}`, { | ||||
|         connectionId, | ||||
|         port: targetSelectionContext.port, | ||||
|         component: 'route-handler' | ||||
|       }); | ||||
|       socket.end(); | ||||
|       this.smartProxy.connectionManager.cleanupConnection(record, 'no_matching_target'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
| @@ -759,9 +856,9 @@ export class RouteConnectionHandler { | ||||
|  | ||||
|     // Determine host using function or static value | ||||
|     let targetHost: string | string[]; | ||||
|     if (typeof action.target.host === 'function') { | ||||
|     if (typeof selectedTarget.host === 'function') { | ||||
|       try { | ||||
|         targetHost = action.target.host(routeContext); | ||||
|         targetHost = selectedTarget.host(routeContext); | ||||
|         if (this.smartProxy.settings.enableDetailedLogging) { | ||||
|           logger.log('info', `Dynamic host resolved to ${Array.isArray(targetHost) ? targetHost.join(', ') : targetHost} for connection ${connectionId}`, { | ||||
|             connectionId, | ||||
| @@ -780,7 +877,7 @@ export class RouteConnectionHandler { | ||||
|         return; | ||||
|       } | ||||
|     } else { | ||||
|       targetHost = action.target.host; | ||||
|       targetHost = selectedTarget.host; | ||||
|     } | ||||
|  | ||||
|     // If an array of hosts, select one randomly for load balancing | ||||
| @@ -790,9 +887,9 @@ export class RouteConnectionHandler { | ||||
|  | ||||
|     // Determine port using function or static value | ||||
|     let targetPort: number; | ||||
|     if (typeof action.target.port === 'function') { | ||||
|     if (typeof selectedTarget.port === 'function') { | ||||
|       try { | ||||
|         targetPort = action.target.port(routeContext); | ||||
|         targetPort = selectedTarget.port(routeContext); | ||||
|         if (this.smartProxy.settings.enableDetailedLogging) { | ||||
|           logger.log('info', `Dynamic port mapping from ${record.localPort} to ${targetPort} for connection ${connectionId}`, { | ||||
|             connectionId, | ||||
| @@ -813,20 +910,27 @@ export class RouteConnectionHandler { | ||||
|         this.smartProxy.connectionManager.cleanupConnection(record, 'port_mapping_error'); | ||||
|         return; | ||||
|       } | ||||
|     } else if (action.target.port === 'preserve') { | ||||
|     } else if (selectedTarget.port === 'preserve') { | ||||
|       // Use incoming port if port is 'preserve' | ||||
|       targetPort = record.localPort; | ||||
|     } else { | ||||
|       // Use static port from configuration | ||||
|       targetPort = action.target.port; | ||||
|       targetPort = selectedTarget.port; | ||||
|     } | ||||
|  | ||||
|     // Store the resolved host in the context | ||||
|     routeContext.targetHost = selectedHost; | ||||
|  | ||||
|     // Get effective settings (target overrides route-level settings) | ||||
|     const effectiveTls = selectedTarget.tls || effectiveTls; | ||||
|     const effectiveWebsocket = selectedTarget.websocket || action.websocket; | ||||
|     const effectiveSendProxyProtocol = selectedTarget.sendProxyProtocol !== undefined  | ||||
|       ? selectedTarget.sendProxyProtocol  | ||||
|       : action.sendProxyProtocol; | ||||
|  | ||||
|     // Determine if this needs TLS handling | ||||
|     if (action.tls) { | ||||
|       switch (action.tls.mode) { | ||||
|     if (effectiveTls) { | ||||
|       switch (effectiveTls.mode) { | ||||
|         case 'passthrough': | ||||
|           // For TLS passthrough, just forward directly | ||||
|           if (this.smartProxy.settings.enableDetailedLogging) { | ||||
| @@ -853,9 +957,9 @@ export class RouteConnectionHandler { | ||||
|           // For TLS termination, use HttpProxy | ||||
|           if (this.smartProxy.httpProxyBridge.getHttpProxy()) { | ||||
|             if (this.smartProxy.settings.enableDetailedLogging) { | ||||
|               logger.log('info', `Using HttpProxy for TLS termination to ${Array.isArray(action.target.host) ? action.target.host.join(', ') : action.target.host} for connection ${connectionId}`, { | ||||
|               logger.log('info', `Using HttpProxy for TLS termination to ${Array.isArray(selectedTarget.host) ? selectedTarget.host.join(', ') : selectedTarget.host} for connection ${connectionId}`, { | ||||
|                 connectionId, | ||||
|                 targetHost: action.target.host, | ||||
|                 targetHost: selectedTarget.host, | ||||
|                 component: 'route-handler' | ||||
|               }); | ||||
|             } | ||||
| @@ -929,10 +1033,10 @@ export class RouteConnectionHandler { | ||||
|       } else { | ||||
|         // Basic forwarding | ||||
|         if (this.smartProxy.settings.enableDetailedLogging) { | ||||
|           logger.log('info', `Using basic forwarding to ${Array.isArray(action.target.host) ? action.target.host.join(', ') : action.target.host}:${action.target.port} for connection ${connectionId}`, { | ||||
|           logger.log('info', `Using basic forwarding to ${Array.isArray(selectedTarget.host) ? selectedTarget.host.join(', ') : selectedTarget.host}:${selectedTarget.port} for connection ${connectionId}`, { | ||||
|             connectionId, | ||||
|             targetHost: action.target.host, | ||||
|             targetPort: action.target.port, | ||||
|             targetHost: selectedTarget.host, | ||||
|             targetPort: selectedTarget.port, | ||||
|             component: 'route-handler' | ||||
|           }); | ||||
|         } | ||||
| @@ -940,27 +1044,27 @@ export class RouteConnectionHandler { | ||||
|         // Get the appropriate host value | ||||
|         let targetHost: string; | ||||
|  | ||||
|         if (typeof action.target.host === 'function') { | ||||
|         if (typeof selectedTarget.host === 'function') { | ||||
|           // For function-based host, use the same routeContext created earlier | ||||
|           const hostResult = action.target.host(routeContext); | ||||
|           const hostResult = selectedTarget.host(routeContext); | ||||
|           targetHost = Array.isArray(hostResult) | ||||
|             ? hostResult[Math.floor(Math.random() * hostResult.length)] | ||||
|             : hostResult; | ||||
|         } else { | ||||
|           // For static host value | ||||
|           targetHost = Array.isArray(action.target.host) | ||||
|             ? action.target.host[Math.floor(Math.random() * action.target.host.length)] | ||||
|             : action.target.host; | ||||
|           targetHost = Array.isArray(selectedTarget.host) | ||||
|             ? selectedTarget.host[Math.floor(Math.random() * selectedTarget.host.length)] | ||||
|             : selectedTarget.host; | ||||
|         } | ||||
|  | ||||
|         // Determine port - either function-based, static, or preserve incoming port | ||||
|         let targetPort: number; | ||||
|         if (typeof action.target.port === 'function') { | ||||
|           targetPort = action.target.port(routeContext); | ||||
|         } else if (action.target.port === 'preserve') { | ||||
|         if (typeof selectedTarget.port === 'function') { | ||||
|           targetPort = selectedTarget.port(routeContext); | ||||
|         } else if (selectedTarget.port === 'preserve') { | ||||
|           targetPort = record.localPort; | ||||
|         } else { | ||||
|           targetPort = action.target.port; | ||||
|           targetPort = selectedTarget.port; | ||||
|         } | ||||
|  | ||||
|         // Update the connection record and context with resolved values | ||||
|   | ||||
| @@ -66,12 +66,9 @@ export function mergeRouteConfigs( | ||||
|       // Otherwise merge the action properties | ||||
|       mergedRoute.action = { ...mergedRoute.action }; | ||||
|        | ||||
|       // Merge target | ||||
|       if (overrideRoute.action.target) { | ||||
|         mergedRoute.action.target = { | ||||
|           ...mergedRoute.action.target, | ||||
|           ...overrideRoute.action.target | ||||
|         }; | ||||
|       // Merge targets | ||||
|       if (overrideRoute.action.targets) { | ||||
|         mergedRoute.action.targets = overrideRoute.action.targets; | ||||
|       } | ||||
|        | ||||
|       // Merge TLS options | ||||
|   | ||||
| @@ -102,29 +102,43 @@ export function validateRouteAction(action: IRouteAction): { valid: boolean; err | ||||
|     errors.push(`Invalid action type: ${action.type}`); | ||||
|   } | ||||
|  | ||||
|   // Validate target for 'forward' action | ||||
|   // Validate targets for 'forward' action | ||||
|   if (action.type === 'forward') { | ||||
|     if (!action.target) { | ||||
|       errors.push('Target is required for forward action'); | ||||
|     if (!action.targets || !Array.isArray(action.targets) || action.targets.length === 0) { | ||||
|       errors.push('Targets array is required for forward action'); | ||||
|     } else { | ||||
|       // Validate each target | ||||
|       action.targets.forEach((target, index) => { | ||||
|         // Validate target host | ||||
|       if (!action.target.host) { | ||||
|         errors.push('Target host is required'); | ||||
|       } else if (typeof action.target.host !== 'string' && | ||||
|                 !Array.isArray(action.target.host) && | ||||
|                 typeof action.target.host !== 'function') { | ||||
|         errors.push('Target host must be a string, array of strings, or function'); | ||||
|         if (!target.host) { | ||||
|           errors.push(`Target[${index}] host is required`); | ||||
|         } else if (typeof target.host !== 'string' && | ||||
|                   !Array.isArray(target.host) && | ||||
|                   typeof target.host !== 'function') { | ||||
|           errors.push(`Target[${index}] host must be a string, array of strings, or function`); | ||||
|         } | ||||
|  | ||||
|         // Validate target port | ||||
|       if (action.target.port === undefined) { | ||||
|         errors.push('Target port is required'); | ||||
|       } else if (typeof action.target.port !== 'number' && | ||||
|                 typeof action.target.port !== 'function') { | ||||
|         errors.push('Target port must be a number or a function'); | ||||
|       } else if (typeof action.target.port === 'number' && !isValidPort(action.target.port)) { | ||||
|         errors.push('Target port must be between 1 and 65535'); | ||||
|         if (target.port === undefined) { | ||||
|           errors.push(`Target[${index}] port is required`); | ||||
|         } else if (typeof target.port !== 'number' && | ||||
|                   typeof target.port !== 'function' && | ||||
|                   target.port !== 'preserve') { | ||||
|           errors.push(`Target[${index}] port must be a number, 'preserve', or a function`); | ||||
|         } else if (typeof target.port === 'number' && !isValidPort(target.port)) { | ||||
|           errors.push(`Target[${index}] port must be between 1 and 65535`); | ||||
|         } | ||||
|          | ||||
|         // Validate match criteria if present | ||||
|         if (target.match) { | ||||
|           if (target.match.ports && !Array.isArray(target.match.ports)) { | ||||
|             errors.push(`Target[${index}] match.ports must be an array`); | ||||
|           } | ||||
|           if (target.match.method && !Array.isArray(target.match.method)) { | ||||
|             errors.push(`Target[${index}] match.method must be an array`); | ||||
|           } | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|  | ||||
|     // Validate TLS options for forward actions | ||||
| @@ -242,7 +256,10 @@ export function hasRequiredPropertiesForAction(route: IRouteConfig, actionType: | ||||
|  | ||||
|   switch (actionType) { | ||||
|     case 'forward': | ||||
|       return !!route.action.target && !!route.action.target.host && !!route.action.target.port; | ||||
|       return !!route.action.targets &&  | ||||
|              Array.isArray(route.action.targets) &&  | ||||
|              route.action.targets.length > 0 && | ||||
|              route.action.targets.every(t => t.host && t.port !== undefined); | ||||
|     case 'socket-handler': | ||||
|       return !!route.action.socketHandler && typeof route.action.socketHandler === 'function'; | ||||
|     default: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user