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 | ## Key Changes | ||||||
| This plan implements the `certProvisionFunction` feature that allows users to provide their own certificate generation logic. The function can either return a custom certificate or delegate back to Let's Encrypt by returning 'http01'. |  | ||||||
|  |  | ||||||
| ### Key Changes | ### 1. Update Route Target Interface | ||||||
| 1. Add `certProvisionFunction` support to CertificateManager | - Add `match` property to `IRouteTarget` for sub-matching within routes | ||||||
| 2. Modify `provisionAcmeCertificate()` to check custom function first | - Add target-specific override properties (tls, websocket, loadBalancing, etc.) | ||||||
| 3. Add certificate expiry parsing for custom certificates | - Add priority field for controlling match order | ||||||
| 4. Support both initial provisioning and renewal |  | ||||||
| 5. Add fallback configuration option |  | ||||||
|  |  | ||||||
| ### Overview | ### 2. Update Route Action Interface | ||||||
| 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. | - Remove singular `target` property | ||||||
|  | - Use only `targets` array (single target = array with one element) | ||||||
|  | - Maintain backwards compatibility during migration | ||||||
|  |  | ||||||
| ### Requirements | ### 3. Implementation Steps | ||||||
| 1. The function should be called for any new certificate provisioning or renewal |  | ||||||
| 2. Must support returning custom certificates or falling back to Let's Encrypt |  | ||||||
| 3. Should integrate seamlessly with the existing certificate lifecycle |  | ||||||
| 4. Must maintain backward compatibility |  | ||||||
|  |  | ||||||
| ### Implementation Steps | #### 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 | #### Phase 2: Route Resolution Logic | ||||||
| **File**: `ts/proxies/smart-proxy/certificate-manager.ts` | - [ ] 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 | #### Phase 3: Code Migration | ||||||
| - [ ] Pass the function from SmartProxy options during initialization | - [ ] Find all occurrences of `action.target` and update to `action.targets[0]` | ||||||
| - [ ] Modify `provisionCertificate()` method to check for custom function first | - [ ] Update route helpers and utilities | ||||||
|  | - [ ] Update certificate manager to handle multiple targets | ||||||
|  | - [ ] Update connection handlers | ||||||
|  |  | ||||||
| #### 2. Implement Custom Certificate Provisioning Logic | #### Phase 4: Testing | ||||||
| **Location**: Modify `provisionAcmeCertificate()` method | - [ ] 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 | ```typescript | ||||||
| private async provisionAcmeCertificate( | // Need separate routes for different ports/paths | ||||||
|   route: IRouteConfig,  | [ | ||||||
|   domains: string[] |   { | ||||||
| ): Promise<void> { |     match: { domains: ['api.example.com'], ports: [80] }, | ||||||
|   const primaryDomain = domains[0]; |     action: { | ||||||
|   const routeName = route.name || primaryDomain; |       type: 'forward', | ||||||
|    |       target: { host: 'backend', port: 8080 }, | ||||||
|   // Check for custom provision function first |       tls: { mode: 'terminate' } | ||||||
|   if (this.certProvisionFunction) { |  | ||||||
|     try { |  | ||||||
|       logger.log('info', `Attempting custom certificate provision for ${primaryDomain}`, { domain: primaryDomain }); |  | ||||||
|       const result = await this.certProvisionFunction(primaryDomain); |  | ||||||
|        |  | ||||||
|       if (result === 'http01') { |  | ||||||
|         logger.log('info', `Custom function returned 'http01', falling back to Let's Encrypt for ${primaryDomain}`); |  | ||||||
|         // Continue with existing ACME logic below |  | ||||||
|       } else { |  | ||||||
|         // Use custom certificate |  | ||||||
|         const customCert = result as plugins.tsclass.network.ICert; |  | ||||||
|          |  | ||||||
|         // Convert to internal certificate format |  | ||||||
|         const certData: ICertificateData = { |  | ||||||
|           cert: customCert.cert, |  | ||||||
|           key: customCert.key, |  | ||||||
|           ca: customCert.ca || '', |  | ||||||
|           issueDate: new Date(), |  | ||||||
|           expiryDate: this.extractExpiryDate(customCert.cert) |  | ||||||
|         }; |  | ||||||
|          |  | ||||||
|         // Store and apply certificate |  | ||||||
|         await this.certStore.saveCertificate(routeName, certData); |  | ||||||
|         await this.applyCertificate(primaryDomain, certData); |  | ||||||
|         this.updateCertStatus(routeName, 'valid', 'custom', certData); |  | ||||||
|          |  | ||||||
|         logger.log('info', `Custom certificate applied for ${primaryDomain}`, {  |  | ||||||
|           domain: primaryDomain, |  | ||||||
|           expiryDate: certData.expiryDate |  | ||||||
|         }); |  | ||||||
|         return; |  | ||||||
|       } |  | ||||||
|     } catch (error) { |  | ||||||
|       logger.log('error', `Custom cert provision failed for ${primaryDomain}: ${error.message}`, { |  | ||||||
|         domain: primaryDomain, |  | ||||||
|         error: error.message |  | ||||||
|       }); |  | ||||||
|       // Configuration option to control fallback behavior |  | ||||||
|       if (this.smartProxy.settings.certProvisionFallbackToAcme !== false) { |  | ||||||
|         logger.log('info', `Falling back to Let's Encrypt for ${primaryDomain}`); |  | ||||||
|       } else { |  | ||||||
|         throw error; |  | ||||||
|       } |  | ||||||
|     } |     } | ||||||
|   } |  | ||||||
|    |  | ||||||
|   // Existing Let's Encrypt logic continues here... |  | ||||||
|   if (!this.smartAcme) { |  | ||||||
|     throw new Error('SmartAcme not initialized...'); |  | ||||||
|   } |  | ||||||
|   // ... rest of existing code |  | ||||||
| } |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| #### 3. Add Helper Method for Certificate Expiry Extraction |  | ||||||
| **New method**: `extractExpiryDate()` |  | ||||||
|  |  | ||||||
| - [ ] Parse PEM certificate to extract expiry date |  | ||||||
| - [ ] Use existing certificate parsing utilities |  | ||||||
| - [ ] Handle parse errors gracefully |  | ||||||
|  |  | ||||||
| ```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 | ```typescript | ||||||
| interface ISmartProxyOptions { | // Single route with multiple targets | ||||||
|   // Existing options... | { | ||||||
|    |   match: { domains: ['api.example.com'], ports: [80, 443] }, | ||||||
|   // Custom certificate provision function |   action: { | ||||||
|   certProvisionFunction?: (domain: string) => Promise<TSmartProxyCertProvisionObject>; |     type: 'forward', | ||||||
|    |     targets: [ | ||||||
|   // Whether to fallback to ACME if custom provision fails |       { | ||||||
|   certProvisionFallbackToAcme?: boolean; // Default: true |         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**: | ## Benefits | ||||||
|    - Log detailed error with domain context | 1. **DRY Configuration**: No need to duplicate common settings across routes | ||||||
|    - Option A: Fallback to Let's Encrypt (safer) | 2. **Flexibility**: Different backends for different ports/paths within same domain | ||||||
|    - Option B: Fail certificate provisioning (stricter) | 3. **Clarity**: All routing for a domain in one place | ||||||
|    - Make this configurable via option? | 4. **Performance**: Single route lookup instead of multiple | ||||||
|  | 5. **Backwards Compatible**: Can migrate gradually | ||||||
|  |  | ||||||
| 2. **Invalid Certificate Returns**: | ## Migration Strategy | ||||||
|    - Validate certificate structure | 1. Keep support for `target` temporarily with deprecation warning | ||||||
|    - Check expiry dates | 2. Auto-convert `target` to `targets: [target]` internally | ||||||
|    - Verify domain match | 3. Update documentation with migration examples | ||||||
|  | 4. Remove `target` support in next major version | ||||||
| ### 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 |  | ||||||
| @@ -209,10 +209,10 @@ tap.test('SmartProxy: Should create instance with route-based config', async () | |||||||
|       }) |       }) | ||||||
|     ], |     ], | ||||||
|     defaults: { |     defaults: { | ||||||
|       target: { |       targets: [{ | ||||||
|         host: 'localhost', |         host: 'localhost', | ||||||
|         port: 8080 |         port: 8080 | ||||||
|       }, |       }], | ||||||
|       security: { |       security: { | ||||||
|         ipAllowList: ['127.0.0.1', '192.168.0.*'], |         ipAllowList: ['127.0.0.1', '192.168.0.*'], | ||||||
|         maxConnections: 100 |         maxConnections: 100 | ||||||
| @@ -333,10 +333,10 @@ tap.test('Edge Case - Complex Path and Headers Matching', async () => { | |||||||
|     }, |     }, | ||||||
|     action: { |     action: { | ||||||
|       type: 'forward', |       type: 'forward', | ||||||
|       target: { |       targets: [{ | ||||||
|         host: 'internal-api', |         host: 'internal-api', | ||||||
|         port: 8080 |         port: 8080 | ||||||
|       }, |       }], | ||||||
|       tls: { |       tls: { | ||||||
|         mode: 'terminate', |         mode: 'terminate', | ||||||
|         certificate: 'auto' |         certificate: 'auto' | ||||||
| @@ -376,10 +376,10 @@ tap.test('Edge Case - Port Range Matching', async () => { | |||||||
|     }, |     }, | ||||||
|     action: { |     action: { | ||||||
|       type: 'forward', |       type: 'forward', | ||||||
|       target: { |       targets: [{ | ||||||
|         host: 'backend', |         host: 'backend', | ||||||
|         port: 3000 |         port: 3000 | ||||||
|       } |       }] | ||||||
|     }, |     }, | ||||||
|     name: 'Port Range Route' |     name: 'Port Range Route' | ||||||
|   }; |   }; | ||||||
| @@ -404,10 +404,10 @@ tap.test('Edge Case - Port Range Matching', async () => { | |||||||
|     }, |     }, | ||||||
|     action: { |     action: { | ||||||
|       type: 'forward', |       type: 'forward', | ||||||
|       target: { |       targets: [{ | ||||||
|         host: 'backend', |         host: 'backend', | ||||||
|         port: 3000 |         port: 3000 | ||||||
|       } |       }] | ||||||
|     }, |     }, | ||||||
|     name: 'Multi Range Route' |     name: 'Multi Range Route' | ||||||
|   }; |   }; | ||||||
|   | |||||||
| @@ -10,7 +10,7 @@ import { ConnectionPool } from './connection-pool.js'; | |||||||
| import { ContextCreator } from './context-creator.js'; | import { ContextCreator } from './context-creator.js'; | ||||||
| import { HttpRequestHandler } from './http-request-handler.js'; | import { HttpRequestHandler } from './http-request-handler.js'; | ||||||
| import { Http2RequestHandler } from './http2-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 type { IRouteContext, IHttpRouteContext } from '../../core/models/route-context.js'; | ||||||
| import { toBaseContext } from '../../core/models/route-context.js'; | import { toBaseContext } from '../../core/models/route-context.js'; | ||||||
| import { TemplateUtils } from '../../core/utils/template-utils.js'; | import { TemplateUtils } from '../../core/utils/template-utils.js'; | ||||||
| @@ -99,6 +99,80 @@ export class RequestHandler { | |||||||
|     return { ...this.defaultHeaders }; |     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 |    * Apply CORS headers to response if configured | ||||||
|    * Implements Phase 5.5: Context-aware CORS handling |    * 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 we found a matching route with forward action, select appropriate target | ||||||
|     if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.target) { |     if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.targets && matchingRoute.action.targets.length > 0) { | ||||||
|       this.logger.debug(`Found matching route: ${matchingRoute.name || 'unnamed'}`); |       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 |       // Extract target information, resolving functions if needed | ||||||
|       let targetHost: string | string[]; |       let targetHost: string | string[]; | ||||||
|       let targetPort: number; |       let targetPort: number; | ||||||
|  |  | ||||||
|       try { |       try { | ||||||
|         // Check function cache for host and resolve or use cached value |         // 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) |           // Generate a function ID for caching (use route name or ID if available) | ||||||
|           const functionId = `host-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; |           const functionId = `host-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; | ||||||
|  |  | ||||||
| @@ -502,7 +590,7 @@ export class RequestHandler { | |||||||
|               this.logger.debug(`Using cached host value for ${functionId}`); |               this.logger.debug(`Using cached host value for ${functionId}`); | ||||||
|             } else { |             } else { | ||||||
|               // Resolve the function and cache the result |               // Resolve the function and cache the result | ||||||
|               const resolvedHost = matchingRoute.action.target.host(toBaseContext(routeContext)); |               const resolvedHost = selectedTarget.host(toBaseContext(routeContext)); | ||||||
|               targetHost = resolvedHost; |               targetHost = resolvedHost; | ||||||
|  |  | ||||||
|               // Cache the result |               // Cache the result | ||||||
| @@ -511,16 +599,16 @@ export class RequestHandler { | |||||||
|             } |             } | ||||||
|           } else { |           } else { | ||||||
|             // No cache available, just resolve |             // No cache available, just resolve | ||||||
|             const resolvedHost = matchingRoute.action.target.host(routeContext); |             const resolvedHost = selectedTarget.host(routeContext); | ||||||
|             targetHost = resolvedHost; |             targetHost = resolvedHost; | ||||||
|             this.logger.debug(`Resolved function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); |             this.logger.debug(`Resolved function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); | ||||||
|           } |           } | ||||||
|         } else { |         } else { | ||||||
|           targetHost = matchingRoute.action.target.host; |           targetHost = selectedTarget.host; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Check function cache for port and resolve or use cached value |         // 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 |           // Generate a function ID for caching | ||||||
|           const functionId = `port-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; |           const functionId = `port-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; | ||||||
|  |  | ||||||
| @@ -532,7 +620,7 @@ export class RequestHandler { | |||||||
|               this.logger.debug(`Using cached port value for ${functionId}`); |               this.logger.debug(`Using cached port value for ${functionId}`); | ||||||
|             } else { |             } else { | ||||||
|               // Resolve the function and cache the result |               // Resolve the function and cache the result | ||||||
|               const resolvedPort = matchingRoute.action.target.port(toBaseContext(routeContext)); |               const resolvedPort = selectedTarget.port(toBaseContext(routeContext)); | ||||||
|               targetPort = resolvedPort; |               targetPort = resolvedPort; | ||||||
|  |  | ||||||
|               // Cache the result |               // Cache the result | ||||||
| @@ -541,12 +629,12 @@ export class RequestHandler { | |||||||
|             } |             } | ||||||
|           } else { |           } else { | ||||||
|             // No cache available, just resolve |             // No cache available, just resolve | ||||||
|             const resolvedPort = matchingRoute.action.target.port(routeContext); |             const resolvedPort = selectedTarget.port(routeContext); | ||||||
|             targetPort = resolvedPort; |             targetPort = resolvedPort; | ||||||
|             this.logger.debug(`Resolved function-based port to: ${resolvedPort}`); |             this.logger.debug(`Resolved function-based port to: ${resolvedPort}`); | ||||||
|           } |           } | ||||||
|         } else { |         } 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 |         // 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 we found a matching route with forward action, select appropriate target | ||||||
|     if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.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'}`); |       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 |       // Extract target information, resolving functions if needed | ||||||
|       let targetHost: string | string[]; |       let targetHost: string | string[]; | ||||||
|       let targetPort: number; |       let targetPort: number; | ||||||
|  |  | ||||||
|       try { |       try { | ||||||
|         // Check function cache for host and resolve or use cached value |         // 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) |           // Generate a function ID for caching (use route name or ID if available) | ||||||
|           const functionId = `host-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; |           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}`); |               this.logger.debug(`Using cached host value for HTTP/2: ${functionId}`); | ||||||
|             } else { |             } else { | ||||||
|               // Resolve the function and cache the result |               // Resolve the function and cache the result | ||||||
|               const resolvedHost = matchingRoute.action.target.host(toBaseContext(routeContext)); |               const resolvedHost = selectedTarget.host(toBaseContext(routeContext)); | ||||||
|               targetHost = resolvedHost; |               targetHost = resolvedHost; | ||||||
|  |  | ||||||
|               // Cache the result |               // Cache the result | ||||||
| @@ -657,16 +760,16 @@ export class RequestHandler { | |||||||
|             } |             } | ||||||
|           } else { |           } else { | ||||||
|             // No cache available, just resolve |             // No cache available, just resolve | ||||||
|             const resolvedHost = matchingRoute.action.target.host(routeContext); |             const resolvedHost = selectedTarget.host(routeContext); | ||||||
|             targetHost = resolvedHost; |             targetHost = resolvedHost; | ||||||
|             this.logger.debug(`Resolved HTTP/2 function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); |             this.logger.debug(`Resolved HTTP/2 function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); | ||||||
|           } |           } | ||||||
|         } else { |         } else { | ||||||
|           targetHost = matchingRoute.action.target.host; |           targetHost = selectedTarget.host; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Check function cache for port and resolve or use cached value |         // 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 |           // Generate a function ID for caching | ||||||
|           const functionId = `port-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`; |           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}`); |               this.logger.debug(`Using cached port value for HTTP/2: ${functionId}`); | ||||||
|             } else { |             } else { | ||||||
|               // Resolve the function and cache the result |               // Resolve the function and cache the result | ||||||
|               const resolvedPort = matchingRoute.action.target.port(toBaseContext(routeContext)); |               const resolvedPort = selectedTarget.port(toBaseContext(routeContext)); | ||||||
|               targetPort = resolvedPort; |               targetPort = resolvedPort; | ||||||
|  |  | ||||||
|               // Cache the result |               // Cache the result | ||||||
| @@ -687,12 +790,12 @@ export class RequestHandler { | |||||||
|             } |             } | ||||||
|           } else { |           } else { | ||||||
|             // No cache available, just resolve |             // No cache available, just resolve | ||||||
|             const resolvedPort = matchingRoute.action.target.port(routeContext); |             const resolvedPort = selectedTarget.port(routeContext); | ||||||
|             targetPort = resolvedPort; |             targetPort = resolvedPort; | ||||||
|             this.logger.debug(`Resolved HTTP/2 function-based port to: ${resolvedPort}`); |             this.logger.debug(`Resolved HTTP/2 function-based port to: ${resolvedPort}`); | ||||||
|           } |           } | ||||||
|         } else { |         } 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 |         // 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 { type IHttpProxyOptions, type IWebSocketWithHeartbeat, type ILogger, createLogger } from './models/types.js'; | ||||||
| import { ConnectionPool } from './connection-pool.js'; | import { ConnectionPool } from './connection-pool.js'; | ||||||
| import { HttpRouter } from '../../routing/router/index.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 type { IRouteContext } from '../../core/models/route-context.js'; | ||||||
| import { toBaseContext } from '../../core/models/route-context.js'; | import { toBaseContext } from '../../core/models/route-context.js'; | ||||||
| import { ContextCreator } from './context-creator.js'; | import { ContextCreator } from './context-creator.js'; | ||||||
| @@ -53,6 +53,80 @@ export class WebSocketHandler { | |||||||
|     this.securityManager.setRoutes(routes); |     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 |    * Initialize WebSocket server on an existing HTTPS server | ||||||
|    */ |    */ | ||||||
| @@ -146,9 +220,23 @@ export class WebSocketHandler { | |||||||
|       let destination: { host: string; port: number }; |       let destination: { host: string; port: number }; | ||||||
|  |  | ||||||
|       // If we found a route with the modern router, use it |       // 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'}`); |         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 |         // Check if WebSockets are enabled for this route | ||||||
|         if (route.action.websocket?.enabled === false) { |         if (route.action.websocket?.enabled === false) { | ||||||
|           this.logger.debug(`WebSockets are disabled for route: ${route.name || 'unnamed'}`); |           this.logger.debug(`WebSockets are disabled for route: ${route.name || 'unnamed'}`); | ||||||
| @@ -192,20 +280,20 @@ export class WebSocketHandler { | |||||||
|  |  | ||||||
|         try { |         try { | ||||||
|           // Resolve host if it's a function |           // Resolve host if it's a function | ||||||
|           if (typeof route.action.target.host === 'function') { |           if (typeof selectedTarget.host === 'function') { | ||||||
|             const resolvedHost = route.action.target.host(toBaseContext(routeContext)); |             const resolvedHost = selectedTarget.host(toBaseContext(routeContext)); | ||||||
|             targetHost = resolvedHost; |             targetHost = resolvedHost; | ||||||
|             this.logger.debug(`Resolved function-based host for WebSocket: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); |             this.logger.debug(`Resolved function-based host for WebSocket: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`); | ||||||
|           } else { |           } else { | ||||||
|             targetHost = route.action.target.host; |             targetHost = selectedTarget.host; | ||||||
|           } |           } | ||||||
|  |  | ||||||
|           // Resolve port if it's a function |           // Resolve port if it's a function | ||||||
|           if (typeof route.action.target.port === 'function') { |           if (typeof selectedTarget.port === 'function') { | ||||||
|             targetPort = route.action.target.port(toBaseContext(routeContext)); |             targetPort = selectedTarget.port(toBaseContext(routeContext)); | ||||||
|             this.logger.debug(`Resolved function-based port for WebSocket: ${targetPort}`); |             this.logger.debug(`Resolved function-based port for WebSocket: ${targetPort}`); | ||||||
|           } else { |           } 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 |           // 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 { | 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 |   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) |   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 |   // Basic routing | ||||||
|   type: TRouteActionType; |   type: TRouteActionType; | ||||||
|  |  | ||||||
|   // Target for forwarding |   // Targets for forwarding (array supports multiple targets with sub-matching) | ||||||
|   target?: IRouteTarget; |   targets: IRouteTarget[]; | ||||||
|  |  | ||||||
|   // TLS handling |   // TLS handling (default for all targets, can be overridden per target) | ||||||
|   tls?: IRouteTls; |   tls?: IRouteTls; | ||||||
|  |  | ||||||
|   // WebSocket support |   // WebSocket support (default for all targets, can be overridden per target) | ||||||
|   websocket?: IRouteWebSocket; |   websocket?: IRouteWebSocket; | ||||||
|  |  | ||||||
|   // Load balancing options |   // Load balancing options (default for all targets, can be overridden per target) | ||||||
|   loadBalancing?: IRouteLoadBalancing; |   loadBalancing?: IRouteLoadBalancing; | ||||||
|  |  | ||||||
|   // Advanced options |   // Advanced options (default for all targets, can be overridden per target) | ||||||
|   advanced?: IRouteAdvanced; |   advanced?: IRouteAdvanced; | ||||||
|    |    | ||||||
|   // Additional options for backend-specific settings |   // Additional options for backend-specific settings | ||||||
| @@ -251,7 +276,7 @@ export interface IRouteAction { | |||||||
|   // Socket handler function (when type is 'socket-handler') |   // Socket handler function (when type is 'socket-handler') | ||||||
|   socketHandler?: TSocketHandler; |   socketHandler?: TSocketHandler; | ||||||
|    |    | ||||||
|   // PROXY protocol support |   // PROXY protocol support (default for all targets, can be overridden per target) | ||||||
|   sendProxyProtocol?: boolean; |   sendProxyProtocol?: boolean; | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -123,39 +123,43 @@ export class NFTablesManager { | |||||||
|   private createNfTablesOptions(route: IRouteConfig): NfTableProxyOptions { |   private createNfTablesOptions(route: IRouteConfig): NfTableProxyOptions { | ||||||
|     const { action } = route; |     const { action } = route; | ||||||
|      |      | ||||||
|     // Ensure we have a target |     // Ensure we have targets | ||||||
|     if (!action.target) { |     if (!action.targets || action.targets.length === 0) { | ||||||
|       throw new Error('Route must have a target to use NFTables forwarding'); |       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 |     // Convert port specifications | ||||||
|     const fromPorts = this.expandPortRange(route.match.ports); |     const fromPorts = this.expandPortRange(route.match.ports); | ||||||
|      |      | ||||||
|     // Determine target port |     // Determine target port | ||||||
|     let toPorts: number | PortRange | Array<number | PortRange>; |     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 |       // 'preserve' means use the same ports as the source | ||||||
|       toPorts = fromPorts; |       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 |       // For function-based ports, we can't determine at setup time | ||||||
|       // Use the "preserve" approach and let NFTables handle it |       // Use the "preserve" approach and let NFTables handle it | ||||||
|       toPorts = fromPorts; |       toPorts = fromPorts; | ||||||
|     } else { |     } else { | ||||||
|       toPorts = action.target.port; |       toPorts = defaultTarget.port; | ||||||
|     } |     } | ||||||
|      |      | ||||||
|     // Determine target host |     // Determine target host | ||||||
|     let toHost: string; |     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 |       // Can't determine at setup time, use localhost as a placeholder | ||||||
|       // and rely on run-time handling |       // and rely on run-time handling | ||||||
|       toHost = 'localhost'; |       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   |       // Use first host for now - NFTables will do simple round-robin   | ||||||
|       toHost = action.target.host[0]; |       toHost = defaultTarget.host[0]; | ||||||
|     } else { |     } else { | ||||||
|       toHost = action.target.host; |       toHost = defaultTarget.host; | ||||||
|     } |     } | ||||||
|      |      | ||||||
|     // Create options |     // Create options | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ import type { IConnectionRecord, ISmartProxyOptions } from './models/interfaces. | |||||||
| import { logger } from '../../core/utils/logger.js'; | import { logger } from '../../core/utils/logger.js'; | ||||||
| import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js'; | import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js'; | ||||||
| // Route checking functions have been removed | // 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 type { IRouteContext } from '../../core/models/route-context.js'; | ||||||
| import { cleanupSocket, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js'; | import { cleanupSocket, setupSocketHandlers, createSocketWithErrorHandler, setupBidirectionalForwarding } from '../../core/utils/socket-utils.js'; | ||||||
| import { WrappedSocket } from '../../core/models/wrapped-socket.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 |    * Handle a forward action for a route | ||||||
|    */ |    */ | ||||||
| @@ -731,14 +805,37 @@ export class RouteConnectionHandler { | |||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // We should have a target configuration for forwarding |     // Select the appropriate target from the targets array | ||||||
|     if (!action.target) { |     if (!action.targets || action.targets.length === 0) { | ||||||
|       logger.log('error', `Forward action missing target configuration for connection ${connectionId}`, { |       logger.log('error', `Forward action missing targets configuration for connection ${connectionId}`, { | ||||||
|         connectionId, |         connectionId, | ||||||
|         component: 'route-handler' |         component: 'route-handler' | ||||||
|       }); |       }); | ||||||
|       socket.end(); |       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; |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -759,9 +856,9 @@ export class RouteConnectionHandler { | |||||||
|  |  | ||||||
|     // Determine host using function or static value |     // Determine host using function or static value | ||||||
|     let targetHost: string | string[]; |     let targetHost: string | string[]; | ||||||
|     if (typeof action.target.host === 'function') { |     if (typeof selectedTarget.host === 'function') { | ||||||
|       try { |       try { | ||||||
|         targetHost = action.target.host(routeContext); |         targetHost = selectedTarget.host(routeContext); | ||||||
|         if (this.smartProxy.settings.enableDetailedLogging) { |         if (this.smartProxy.settings.enableDetailedLogging) { | ||||||
|           logger.log('info', `Dynamic host resolved to ${Array.isArray(targetHost) ? targetHost.join(', ') : targetHost} for connection ${connectionId}`, { |           logger.log('info', `Dynamic host resolved to ${Array.isArray(targetHost) ? targetHost.join(', ') : targetHost} for connection ${connectionId}`, { | ||||||
|             connectionId, |             connectionId, | ||||||
| @@ -780,7 +877,7 @@ export class RouteConnectionHandler { | |||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|     } else { |     } else { | ||||||
|       targetHost = action.target.host; |       targetHost = selectedTarget.host; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // If an array of hosts, select one randomly for load balancing |     // 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 |     // Determine port using function or static value | ||||||
|     let targetPort: number; |     let targetPort: number; | ||||||
|     if (typeof action.target.port === 'function') { |     if (typeof selectedTarget.port === 'function') { | ||||||
|       try { |       try { | ||||||
|         targetPort = action.target.port(routeContext); |         targetPort = selectedTarget.port(routeContext); | ||||||
|         if (this.smartProxy.settings.enableDetailedLogging) { |         if (this.smartProxy.settings.enableDetailedLogging) { | ||||||
|           logger.log('info', `Dynamic port mapping from ${record.localPort} to ${targetPort} for connection ${connectionId}`, { |           logger.log('info', `Dynamic port mapping from ${record.localPort} to ${targetPort} for connection ${connectionId}`, { | ||||||
|             connectionId, |             connectionId, | ||||||
| @@ -813,20 +910,27 @@ export class RouteConnectionHandler { | |||||||
|         this.smartProxy.connectionManager.cleanupConnection(record, 'port_mapping_error'); |         this.smartProxy.connectionManager.cleanupConnection(record, 'port_mapping_error'); | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
|     } else if (action.target.port === 'preserve') { |     } else if (selectedTarget.port === 'preserve') { | ||||||
|       // Use incoming port if port is 'preserve' |       // Use incoming port if port is 'preserve' | ||||||
|       targetPort = record.localPort; |       targetPort = record.localPort; | ||||||
|     } else { |     } else { | ||||||
|       // Use static port from configuration |       // Use static port from configuration | ||||||
|       targetPort = action.target.port; |       targetPort = selectedTarget.port; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Store the resolved host in the context |     // Store the resolved host in the context | ||||||
|     routeContext.targetHost = selectedHost; |     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 |     // Determine if this needs TLS handling | ||||||
|     if (action.tls) { |     if (effectiveTls) { | ||||||
|       switch (action.tls.mode) { |       switch (effectiveTls.mode) { | ||||||
|         case 'passthrough': |         case 'passthrough': | ||||||
|           // For TLS passthrough, just forward directly |           // For TLS passthrough, just forward directly | ||||||
|           if (this.smartProxy.settings.enableDetailedLogging) { |           if (this.smartProxy.settings.enableDetailedLogging) { | ||||||
| @@ -853,9 +957,9 @@ export class RouteConnectionHandler { | |||||||
|           // For TLS termination, use HttpProxy |           // For TLS termination, use HttpProxy | ||||||
|           if (this.smartProxy.httpProxyBridge.getHttpProxy()) { |           if (this.smartProxy.httpProxyBridge.getHttpProxy()) { | ||||||
|             if (this.smartProxy.settings.enableDetailedLogging) { |             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, |                 connectionId, | ||||||
|                 targetHost: action.target.host, |                 targetHost: selectedTarget.host, | ||||||
|                 component: 'route-handler' |                 component: 'route-handler' | ||||||
|               }); |               }); | ||||||
|             } |             } | ||||||
| @@ -929,10 +1033,10 @@ export class RouteConnectionHandler { | |||||||
|       } else { |       } else { | ||||||
|         // Basic forwarding |         // Basic forwarding | ||||||
|         if (this.smartProxy.settings.enableDetailedLogging) { |         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, |             connectionId, | ||||||
|             targetHost: action.target.host, |             targetHost: selectedTarget.host, | ||||||
|             targetPort: action.target.port, |             targetPort: selectedTarget.port, | ||||||
|             component: 'route-handler' |             component: 'route-handler' | ||||||
|           }); |           }); | ||||||
|         } |         } | ||||||
| @@ -940,27 +1044,27 @@ export class RouteConnectionHandler { | |||||||
|         // Get the appropriate host value |         // Get the appropriate host value | ||||||
|         let targetHost: string; |         let targetHost: string; | ||||||
|  |  | ||||||
|         if (typeof action.target.host === 'function') { |         if (typeof selectedTarget.host === 'function') { | ||||||
|           // For function-based host, use the same routeContext created earlier |           // 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) |           targetHost = Array.isArray(hostResult) | ||||||
|             ? hostResult[Math.floor(Math.random() * hostResult.length)] |             ? hostResult[Math.floor(Math.random() * hostResult.length)] | ||||||
|             : hostResult; |             : hostResult; | ||||||
|         } else { |         } else { | ||||||
|           // For static host value |           // For static host value | ||||||
|           targetHost = Array.isArray(action.target.host) |           targetHost = Array.isArray(selectedTarget.host) | ||||||
|             ? action.target.host[Math.floor(Math.random() * action.target.host.length)] |             ? selectedTarget.host[Math.floor(Math.random() * selectedTarget.host.length)] | ||||||
|             : action.target.host; |             : selectedTarget.host; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Determine port - either function-based, static, or preserve incoming port |         // Determine port - either function-based, static, or preserve incoming port | ||||||
|         let targetPort: number; |         let targetPort: number; | ||||||
|         if (typeof action.target.port === 'function') { |         if (typeof selectedTarget.port === 'function') { | ||||||
|           targetPort = action.target.port(routeContext); |           targetPort = selectedTarget.port(routeContext); | ||||||
|         } else if (action.target.port === 'preserve') { |         } else if (selectedTarget.port === 'preserve') { | ||||||
|           targetPort = record.localPort; |           targetPort = record.localPort; | ||||||
|         } else { |         } else { | ||||||
|           targetPort = action.target.port; |           targetPort = selectedTarget.port; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Update the connection record and context with resolved values |         // Update the connection record and context with resolved values | ||||||
|   | |||||||
| @@ -66,12 +66,9 @@ export function mergeRouteConfigs( | |||||||
|       // Otherwise merge the action properties |       // Otherwise merge the action properties | ||||||
|       mergedRoute.action = { ...mergedRoute.action }; |       mergedRoute.action = { ...mergedRoute.action }; | ||||||
|        |        | ||||||
|       // Merge target |       // Merge targets | ||||||
|       if (overrideRoute.action.target) { |       if (overrideRoute.action.targets) { | ||||||
|         mergedRoute.action.target = { |         mergedRoute.action.targets = overrideRoute.action.targets; | ||||||
|           ...mergedRoute.action.target, |  | ||||||
|           ...overrideRoute.action.target |  | ||||||
|         }; |  | ||||||
|       } |       } | ||||||
|        |        | ||||||
|       // Merge TLS options |       // Merge TLS options | ||||||
|   | |||||||
| @@ -102,29 +102,43 @@ export function validateRouteAction(action: IRouteAction): { valid: boolean; err | |||||||
|     errors.push(`Invalid action type: ${action.type}`); |     errors.push(`Invalid action type: ${action.type}`); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Validate target for 'forward' action |   // Validate targets for 'forward' action | ||||||
|   if (action.type === 'forward') { |   if (action.type === 'forward') { | ||||||
|     if (!action.target) { |     if (!action.targets || !Array.isArray(action.targets) || action.targets.length === 0) { | ||||||
|       errors.push('Target is required for forward action'); |       errors.push('Targets array is required for forward action'); | ||||||
|     } else { |     } else { | ||||||
|       // Validate target host |       // Validate each target | ||||||
|       if (!action.target.host) { |       action.targets.forEach((target, index) => { | ||||||
|         errors.push('Target host is required'); |         // Validate target host | ||||||
|       } else if (typeof action.target.host !== 'string' && |         if (!target.host) { | ||||||
|                 !Array.isArray(action.target.host) && |           errors.push(`Target[${index}] host is required`); | ||||||
|                 typeof action.target.host !== 'function') { |         } else if (typeof target.host !== 'string' && | ||||||
|         errors.push('Target host must be a string, array of strings, or function'); |                   !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 |         // Validate target port | ||||||
|       if (action.target.port === undefined) { |         if (target.port === undefined) { | ||||||
|         errors.push('Target port is required'); |           errors.push(`Target[${index}] port is required`); | ||||||
|       } else if (typeof action.target.port !== 'number' && |         } else if (typeof target.port !== 'number' && | ||||||
|                 typeof action.target.port !== 'function') { |                   typeof target.port !== 'function' && | ||||||
|         errors.push('Target port must be a number or a function'); |                   target.port !== 'preserve') { | ||||||
|       } else if (typeof action.target.port === 'number' && !isValidPort(action.target.port)) { |           errors.push(`Target[${index}] port must be a number, 'preserve', or a function`); | ||||||
|         errors.push('Target port must be between 1 and 65535'); |         } 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 |     // Validate TLS options for forward actions | ||||||
| @@ -242,7 +256,10 @@ export function hasRequiredPropertiesForAction(route: IRouteConfig, actionType: | |||||||
|  |  | ||||||
|   switch (actionType) { |   switch (actionType) { | ||||||
|     case 'forward': |     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': |     case 'socket-handler': | ||||||
|       return !!route.action.socketHandler && typeof route.action.socketHandler === 'function'; |       return !!route.action.socketHandler && typeof route.action.socketHandler === 'function'; | ||||||
|     default: |     default: | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user