diff --git a/readme.plan.md b/readme.plan.md index 20b6325..4bb55f4 100644 --- a/readme.plan.md +++ b/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 { - const primaryDomain = domains[0]; - const routeName = route.name || primaryDomain; - - // Check for custom provision function first - if (this.certProvisionFunction) { - try { - logger.log('info', `Attempting custom certificate provision for ${primaryDomain}`, { domain: primaryDomain }); - const result = await this.certProvisionFunction(primaryDomain); - - if (result === 'http01') { - logger.log('info', `Custom function returned 'http01', falling back to Let's Encrypt for ${primaryDomain}`); - // Continue with existing ACME logic below - } else { - // Use custom certificate - const customCert = result as plugins.tsclass.network.ICert; - - // Convert to internal certificate format - const certData: ICertificateData = { - cert: customCert.cert, - key: customCert.key, - ca: customCert.ca || '', - issueDate: new Date(), - expiryDate: this.extractExpiryDate(customCert.cert) - }; - - // Store and apply certificate - await this.certStore.saveCertificate(routeName, certData); - await this.applyCertificate(primaryDomain, certData); - this.updateCertStatus(routeName, 'valid', 'custom', certData); - - logger.log('info', `Custom certificate applied for ${primaryDomain}`, { - domain: primaryDomain, - expiryDate: certData.expiryDate - }); - return; - } - } catch (error) { - logger.log('error', `Custom cert provision failed for ${primaryDomain}: ${error.message}`, { - domain: primaryDomain, - error: error.message - }); - // Configuration option to control fallback behavior - if (this.smartProxy.settings.certProvisionFallbackToAcme !== false) { - logger.log('info', `Falling back to Let's Encrypt for ${primaryDomain}`); - } else { - throw error; - } +// 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' } } - } - - // 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 { - // 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; - - // 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 \ No newline at end of file +## 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 \ No newline at end of file diff --git a/test/test.route-config.ts b/test/test.route-config.ts index 6faee68..f4125e4 100644 --- a/test/test.route-config.ts +++ b/test/test.route-config.ts @@ -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' }; diff --git a/ts/proxies/http-proxy/request-handler.ts b/ts/proxies/http-proxy/request-handler.ts index a03a9eb..e102f5c 100644 --- a/ts/proxies/http-proxy/request-handler.ts +++ b/ts/proxies/http-proxy/request-handler.ts @@ -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; + 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 diff --git a/ts/proxies/http-proxy/websocket-handler.ts b/ts/proxies/http-proxy/websocket-handler.ts index a9cac2d..9f496ad 100644 --- a/ts/proxies/http-proxy/websocket-handler.ts +++ b/ts/proxies/http-proxy/websocket-handler.ts @@ -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; + 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 diff --git a/ts/proxies/smart-proxy/models/route-types.ts b/ts/proxies/smart-proxy/models/route-types.ts index bbd936c..98ad0db 100644 --- a/ts/proxies/smart-proxy/models/route-types.ts +++ b/ts/proxies/smart-proxy/models/route-types.ts @@ -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; // 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; } diff --git a/ts/proxies/smart-proxy/nftables-manager.ts b/ts/proxies/smart-proxy/nftables-manager.ts index 586ad84..192bcc0 100644 --- a/ts/proxies/smart-proxy/nftables-manager.ts +++ b/ts/proxies/smart-proxy/nftables-manager.ts @@ -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; - 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 diff --git a/ts/proxies/smart-proxy/route-connection-handler.ts b/ts/proxies/smart-proxy/route-connection-handler.ts index b6a8445..68e41e8 100644 --- a/ts/proxies/smart-proxy/route-connection-handler.ts +++ b/ts/proxies/smart-proxy/route-connection-handler.ts @@ -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; + 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 diff --git a/ts/proxies/smart-proxy/utils/route-utils.ts b/ts/proxies/smart-proxy/utils/route-utils.ts index 770fafe..0df5cd4 100644 --- a/ts/proxies/smart-proxy/utils/route-utils.ts +++ b/ts/proxies/smart-proxy/utils/route-utils.ts @@ -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 diff --git a/ts/proxies/smart-proxy/utils/route-validators.ts b/ts/proxies/smart-proxy/utils/route-validators.ts index da3417e..187c50a 100644 --- a/ts/proxies/smart-proxy/utils/route-validators.ts +++ b/ts/proxies/smart-proxy/utils/route-validators.ts @@ -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 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'); - } + // Validate each target + action.targets.forEach((target, index) => { + // Validate target host + 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'); - } + // Validate target port + 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: