fix(network-proxy, route-utils, route-manager): Normalize IPv6-mapped IPv4 addresses in IP matching functions and remove deprecated legacy configuration methods in NetworkProxy. Update route-utils and route-manager to compare both canonical and IPv6-mapped IP forms, adjust tests accordingly, and clean up legacy exports.
This commit is contained in:
		| @@ -1,5 +1,14 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## 2025-05-14 - 16.0.3 - fix(network-proxy, route-utils, route-manager) | ||||
| Normalize IPv6-mapped IPv4 addresses in IP matching functions and remove deprecated legacy configuration methods in NetworkProxy. Update route-utils and route-manager to compare both canonical and IPv6-mapped IP forms, adjust tests accordingly, and clean up legacy exports. | ||||
|  | ||||
| - Updated matchIpPattern and matchIpCidr to normalize IPv6-mapped IPv4 addresses. | ||||
| - Replaced legacy 'domain' field references with 'domains' in route configurations. | ||||
| - Removed deprecated methods for converting legacy proxy configs and legacy route helpers. | ||||
| - Adjusted test cases (event system, route utils, network proxy function targets) to use modern interfaces. | ||||
| - Improved logging and error messages in route-manager and route-utils for better debugging. | ||||
|  | ||||
| ## 2025-05-10 - 16.0.2 - fix(test/certificate-provisioning) | ||||
| Update certificate provisioning tests with updated port mapping and ACME options; use accountEmail instead of contactEmail, adjust auto-api route creation to use HTTPS terminate helper, and refine expectations for wildcard passthrough domains. | ||||
|  | ||||
|   | ||||
							
								
								
									
										192
									
								
								readme.plan.md
									
									
									
									
									
								
							
							
						
						
									
										192
									
								
								readme.plan.md
									
									
									
									
									
								
							| @@ -1,139 +1,103 @@ | ||||
| # Enhanced NetworkProxy with Native Route-Based Configuration | ||||
| # SmartProxy Configuration Troubleshooting | ||||
|  | ||||
| ## Project Goal | ||||
| Transform NetworkProxy to natively use route-based configurations (`IRouteConfig`) as its primary configuration format, completely eliminating translation layers while maintaining backward compatibility through adapter methods for existing code. | ||||
| ## IPv6/IPv4 Mapping Issue | ||||
|  | ||||
| ## Current Status | ||||
| ### Problem Identified | ||||
| The SmartProxy is failing to match connections for wildcard domains (like `*.lossless.digital`) when IP restrictions are in place. After extensive debugging, the root cause has been identified: | ||||
|  | ||||
| The current implementation uses: | ||||
| - SmartProxy has a rich `IRouteConfig` format with match/action pattern | ||||
| - NetworkProxy uses a simpler `IReverseProxyConfig` focused on hostname and destination | ||||
| - `NetworkProxyBridge` translates between these formats, losing information | ||||
| - Dynamic function-based hosts and ports aren't supported in NetworkProxy | ||||
| - Duplicate configuration logic exists across components | ||||
| When a connection comes in from an IPv4 address (e.g., `212.95.99.130`), the Node.js server receives it as an IPv6-mapped IPv4 address with the format `::ffff:212.95.99.130`. However, the route configuration is expecting the exact string `212.95.99.130`, causing a mismatch. | ||||
|  | ||||
| ## Planned Enhancements | ||||
| From the debug logs: | ||||
| ``` | ||||
| [DEBUG] Route rejected: clientIp mismatch. Request: ::ffff:212.95.99.130, Route patterns: ["212.95.99.130"] | ||||
| ``` | ||||
|  | ||||
| ### Phase 1: Convert NetworkProxy to Native Route Configuration | ||||
| - [x] 1.1 Refactor NetworkProxy to use `IRouteConfig` as its primary internal format | ||||
| - [x] 1.3 Update all internal processing to work directly with route configs | ||||
| - [x] 1.4 Add a type-safe context object matching SmartProxy's | ||||
| - [x] 1.5 Ensure backward compatibility for all existing NetworkProxy methods | ||||
| - [x] 1.6 Remove `IReverseProxyConfig` usage in NetworkProxy | ||||
| ### Solution | ||||
|  | ||||
| ### Phase 2: Native Route Configuration Processing | ||||
| - [x] 2.1 Make `updateRouteConfigs(routes: IRouteConfig[])` the primary configuration method | ||||
| - [x] 2.3 Implement a full RouteManager in NetworkProxy (reusing code from SmartProxy if possible) | ||||
| - [x] 2.4 Support all route matching criteria (domains, paths, headers, clientIp) | ||||
| - [x] 2.5 Handle priority-based route matching and conflict resolution | ||||
| - [x] 2.6 Update certificate management to work with routes directly | ||||
| To fix this issue, update the route configurations to include both formats of the IP address. Here's how to modify the affected route: | ||||
|  | ||||
| ### Phase 3: Simplify NetworkProxyBridge | ||||
| - [x] 3.1 Update NetworkProxyBridge to directly pass route configs to NetworkProxy | ||||
| - [x] 3.2 Remove all translation/conversion logic in the bridge | ||||
| - [x] 3.3 Simplify domain registration from routes to Port80Handler | ||||
| - [x] 3.4 Make the bridge a lightweight pass-through component | ||||
| - [x] 3.5 Add comprehensive logging for route synchronization | ||||
| - [x] 3.6 Streamline certificate handling between components | ||||
| ```typescript | ||||
| // Wildcard domain route for *.lossless.digital | ||||
| { | ||||
|   match: { | ||||
|     ports: 443, | ||||
|     domains: ['*.lossless.digital'], | ||||
|     clientIp: ['212.95.99.130', '::ffff:212.95.99.130'],  // Include both formats | ||||
|   }, | ||||
|   action: { | ||||
|     type: 'forward', | ||||
|     target: { | ||||
|       host: '212.95.99.130', | ||||
|       port: 443 | ||||
|     }, | ||||
|     tls: { | ||||
|       mode: 'passthrough' | ||||
|     }, | ||||
|     security: { | ||||
|       allowedIps: ['212.95.99.130', '::ffff:212.95.99.130']  // Include both formats | ||||
|     } | ||||
|   }, | ||||
|   name: 'Wildcard lossless.digital route (IP restricted)' | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### Phase 4: Native Function-Based Target Support | ||||
| - [x] 4.1 Implement IRouteContext creation in NetworkProxy's request handler | ||||
| - [x] 4.2 Add direct support for function-based host evaluation | ||||
| - [x] 4.3 Add direct support for function-based port evaluation | ||||
| - [x] 4.4 Implement caching for function results to improve performance | ||||
| - [x] 4.5 Add comprehensive error handling for function execution | ||||
| - [x] 4.6 Share context object implementation with SmartProxy | ||||
| ### Alternative Long-Term Fix | ||||
|  | ||||
| ### Phase 5: Enhanced HTTP Features Using Route Logic | ||||
| - [x] 5.1 Implement full route-based header manipulation | ||||
| - [x] 5.2 Add support for URL rewriting using route context | ||||
| - [x] 5.3 Support template variable resolution for strings | ||||
| - [x] 5.4 Implement route security features (IP filtering, rate limiting) | ||||
| - [x] 5.5 Add context-aware CORS handling | ||||
| - [x] 5.6 Enable route-based WebSocket upgrades | ||||
| A more robust solution would be to modify the SmartProxy codebase to automatically handle IPv6-mapped IPv4 addresses by normalizing them before comparison. This would involve: | ||||
|  | ||||
| ### Phase 6: Testing, Documentation and Code Sharing | ||||
| - [x] 6.1 Create comprehensive tests for native route configuration | ||||
| - [x] 6.2 Add specific tests for function-based targets | ||||
| - [x] 6.3 Document NetworkProxy's native route capabilities | ||||
| - [x] 6.4 Create shared utilities between SmartProxy and NetworkProxy | ||||
| - [x] 6.5 Provide migration guide for direct NetworkProxy users | ||||
| - [ ] 6.6 Benchmark performance improvements | ||||
| 1. Modifying the `matchIpPattern` function in `route-manager.ts` to normalize IPv6-mapped IPv4 addresses: | ||||
|  | ||||
| ### Phase 7: Unify Component Architecture | ||||
| - [x] 7.1 Implement a shared RouteManager used by both SmartProxy and NetworkProxy | ||||
| - [x] 7.2 Extract common route matching logic to a shared utility module | ||||
| - [x] 7.3 Consolidate duplicate security management code | ||||
| - [x] 7.4 Remove all legacy NetworkProxyBridge conversion code | ||||
| - [x] 7.5 Make the NetworkProxyBridge a pure proxy pass-through component | ||||
| - [x] 7.6 Standardize event naming and handling across components | ||||
| ```typescript | ||||
| private matchIpPattern(pattern: string, ip: string): boolean { | ||||
|   // Normalize IPv6-mapped IPv4 addresses | ||||
|   const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip; | ||||
|   const normalizedPattern = pattern.startsWith('::ffff:') ? pattern.substring(7) : pattern; | ||||
|    | ||||
|   // Handle exact match with normalized addresses | ||||
|   if (normalizedPattern === normalizedIp) { | ||||
|     return true; | ||||
|   } | ||||
|    | ||||
|   // Rest of the existing function... | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### Phase 8: Certificate Management Consolidation | ||||
| - [x] 8.1 Create a unified CertificateManager component | ||||
| - [x] 8.2 Centralize certificate storage and renewal logic | ||||
| - [x] 8.3 Simplify ACME challenge handling across proxies | ||||
| - [x] 8.4 Implement shared certificate events for all components | ||||
| - [x] 8.5 Remove redundant certificate synchronization logic | ||||
| - [x] 8.6 Standardize SNI handling between different proxies | ||||
| 2. Making similar modifications to other IP-related functions in the codebase. | ||||
|  | ||||
| ### Phase 9: Context and Configuration Standardization | ||||
| - [x] 9.1 Implement a single shared IRouteContext class | ||||
| - [x] 9.2 Remove all duplicate context creation logic | ||||
| - [x] 9.3 Standardize option interfaces across components | ||||
| - [x] 9.4 Create shared default configurations | ||||
| - [x] 9.5 Implement a unified configuration validation system | ||||
| - [x] 9.6 Add runtime type checking for configurations | ||||
| ## Wild Card Domain Matching Issue | ||||
|  | ||||
| ### Phase 10: Component Consolidation | ||||
| - [x] 10.1 Merge SmartProxy and NetworkProxy functionality where appropriate | ||||
| - [x] 10.2 Create a unified connection pool management system | ||||
| - [x] 10.3 Standardize timeout handling across components | ||||
| - [x] 10.4 Implement shared logging and monitoring | ||||
| - [x] 10.5 Remove all deprecated methods and legacy compatibility | ||||
| - [x] 10.6 Reduce API surface area to essentials | ||||
| ### Explanation | ||||
|  | ||||
| ### Phase 11: Performance Optimization & Advanced Features | ||||
| - [ ] 11.1 Conduct comprehensive performance benchmarking | ||||
| - [ ] 11.2 Optimize memory usage in high-connection scenarios | ||||
| - [ ] 11.3 Implement connection pooling for backend targets | ||||
| - [ ] 11.4 Add support for HTTP/3 and QUIC protocols | ||||
| - [ ] 11.5 Enhance WebSocket support with compression and multiplexing | ||||
| - [ ] 11.6 Add advanced observability through metrics and tracing integration | ||||
| The wildcard domain matching in SmartProxy works as follows: | ||||
|  | ||||
| ## Benefits of Simplified Architecture | ||||
| 1. When a pattern like `*.lossless.digital` is specified, it's converted to a regex: `/^.*\.lossless\.digital$/i` | ||||
| 2. This correctly matches any subdomain like `my.lossless.digital`, `api.lossless.digital`, etc. | ||||
| 3. However, it does NOT match the apex domain `lossless.digital` (without a subdomain) | ||||
|  | ||||
| 1. **Reduced Duplication**: | ||||
|    - Shared route processing logic | ||||
|    - Single certificate management system | ||||
|    - Unified context objects | ||||
| If you need to match both the apex domain and subdomains, use a list: | ||||
| ```typescript | ||||
| domains: ['lossless.digital', '*.lossless.digital'] | ||||
| ``` | ||||
|  | ||||
| 2. **Simplified Codebase**: | ||||
|    - Fewer managers with cleaner responsibilities | ||||
|    - Consistent APIs across components | ||||
|    - Reduced complexity in bridge components | ||||
| ## Debugging SmartProxy | ||||
|  | ||||
| 3. **Improved Maintainability**: | ||||
|    - Easier to understand component relationships | ||||
|    - Consolidated logic for critical operations | ||||
|    - Clearer separation of concerns | ||||
| To debug routing issues in SmartProxy: | ||||
|  | ||||
| 4. **Enhanced Performance**: | ||||
|    - Less overhead in communication between components | ||||
|    - Reduced memory usage through shared objects | ||||
|    - More efficient request processing | ||||
| 1. Add detailed logging to the `route-manager.js` file in the `dist_ts` directory: | ||||
|    - `findMatchingRoute` method - to see what criteria are being checked | ||||
|    - `matchRouteDomain` method - to see domain matching logic | ||||
|    - `matchDomain` method - to see pattern matching | ||||
|    - `matchIpPattern` method - to see IP matching logic | ||||
|  | ||||
| 5. **Better Developer Experience**: | ||||
|    - Consistent conceptual model across system | ||||
|    - More intuitive configuration interface | ||||
|    - Simplified debugging and troubleshooting | ||||
| 2. Run the proxy with debugging enabled: | ||||
|    ``` | ||||
|    pnpm run startNew | ||||
|    ``` | ||||
|  | ||||
| ## Implementation Approach | ||||
| 3. Monitor the logs for detailed information about the routing process and identify where matches are failing. | ||||
|  | ||||
| The implementation of phases 7-10 will focus on gradually consolidating duplicate functionality: | ||||
| ## Priority and Route Order | ||||
|  | ||||
| 1. First, implement shared managers and utilities to be used by both proxies | ||||
| 2. Then consolidate certificate management to simplify ACME handling | ||||
| 3. Create standardized context objects and configurations | ||||
| 4. Finally, merge overlapping functionality between proxy components | ||||
| Remember that routes are evaluated in priority order (higher priority first). If multiple routes could match the same request, ensure that the more specific routes have higher priority. | ||||
|  | ||||
| This approach will maintain compatibility with existing code while progressively simplifying the architecture to reduce complexity and improve performance. | ||||
| When routes have the same priority (or none specified), they're evaluated in the order they're defined in the configuration. | ||||
| @@ -1,202 +1,207 @@ | ||||
| import { expect } from '@push.rocks/tapbundle'; | ||||
| import { expect, tap } from '@push.rocks/tapbundle'; | ||||
| import { | ||||
|   EventSystem, | ||||
|   ProxyEvents, | ||||
|   ComponentType | ||||
| } from '../../../ts/core/utils/event-system.js'; | ||||
|  | ||||
| // Test event system | ||||
| expect.describe('Event System', async () => { | ||||
|   let eventSystem: EventSystem; | ||||
|   let receivedEvents: any[] = []; | ||||
| // Setup function for creating a new event system | ||||
| function setupEventSystem(): { eventSystem: EventSystem, receivedEvents: any[] } { | ||||
|   const eventSystem = new EventSystem(ComponentType.SMART_PROXY, 'test-id'); | ||||
|   const receivedEvents: any[] = []; | ||||
|   return { eventSystem, receivedEvents }; | ||||
| } | ||||
|  | ||||
| tap.test('Event System - certificate events with correct structure', async () => { | ||||
|   const { eventSystem, receivedEvents } = setupEventSystem(); | ||||
|    | ||||
|   // Set up a new event system before each test | ||||
|   expect.beforeEach(() => { | ||||
|     eventSystem = new EventSystem(ComponentType.SMART_PROXY, 'test-id'); | ||||
|     receivedEvents = []; | ||||
|   // Set up listeners | ||||
|   eventSystem.on(ProxyEvents.CERTIFICATE_ISSUED, (data) => { | ||||
|     receivedEvents.push({ | ||||
|       type: 'issued', | ||||
|       data | ||||
|     }); | ||||
|   }); | ||||
|    | ||||
|   expect.it('should emit certificate events with correct structure', async () => { | ||||
|     // Set up listeners | ||||
|     eventSystem.on(ProxyEvents.CERTIFICATE_ISSUED, (data) => { | ||||
|       receivedEvents.push({ | ||||
|         type: 'issued', | ||||
|         data | ||||
|       }); | ||||
|   eventSystem.on(ProxyEvents.CERTIFICATE_RENEWED, (data) => { | ||||
|     receivedEvents.push({ | ||||
|       type: 'renewed', | ||||
|       data | ||||
|     }); | ||||
|      | ||||
|     eventSystem.on(ProxyEvents.CERTIFICATE_RENEWED, (data) => { | ||||
|       receivedEvents.push({ | ||||
|         type: 'renewed', | ||||
|         data | ||||
|       }); | ||||
|     }); | ||||
|      | ||||
|     // Emit events | ||||
|     eventSystem.emitCertificateIssued({ | ||||
|       domain: 'example.com', | ||||
|       certificate: 'cert-content', | ||||
|       privateKey: 'key-content', | ||||
|       expiryDate: new Date('2025-01-01') | ||||
|     }); | ||||
|      | ||||
|     eventSystem.emitCertificateRenewed({ | ||||
|       domain: 'example.com', | ||||
|       certificate: 'new-cert-content', | ||||
|       privateKey: 'new-key-content', | ||||
|       expiryDate: new Date('2026-01-01'), | ||||
|       isRenewal: true | ||||
|     }); | ||||
|      | ||||
|     // Verify events | ||||
|     expect(receivedEvents.length).to.equal(2); | ||||
|      | ||||
|     // Check issuance event | ||||
|     expect(receivedEvents[0].type).to.equal('issued'); | ||||
|     expect(receivedEvents[0].data.domain).to.equal('example.com'); | ||||
|     expect(receivedEvents[0].data.certificate).to.equal('cert-content'); | ||||
|     expect(receivedEvents[0].data.componentType).to.equal(ComponentType.SMART_PROXY); | ||||
|     expect(receivedEvents[0].data.componentId).to.equal('test-id'); | ||||
|     expect(receivedEvents[0].data.timestamp).to.be.a('number'); | ||||
|      | ||||
|     // Check renewal event | ||||
|     expect(receivedEvents[1].type).to.equal('renewed'); | ||||
|     expect(receivedEvents[1].data.domain).to.equal('example.com'); | ||||
|     expect(receivedEvents[1].data.isRenewal).to.be.true; | ||||
|     expect(receivedEvents[1].data.expiryDate).to.deep.equal(new Date('2026-01-01')); | ||||
|   }); | ||||
|    | ||||
|   expect.it('should emit component lifecycle events', async () => { | ||||
|     // Set up listeners | ||||
|     eventSystem.on(ProxyEvents.COMPONENT_STARTED, (data) => { | ||||
|       receivedEvents.push({ | ||||
|         type: 'started', | ||||
|         data | ||||
|       }); | ||||
|     }); | ||||
|      | ||||
|     eventSystem.on(ProxyEvents.COMPONENT_STOPPED, (data) => { | ||||
|       receivedEvents.push({ | ||||
|         type: 'stopped', | ||||
|         data | ||||
|       }); | ||||
|     }); | ||||
|      | ||||
|     // Emit events | ||||
|     eventSystem.emitComponentStarted('TestComponent', '1.0.0'); | ||||
|     eventSystem.emitComponentStopped('TestComponent'); | ||||
|      | ||||
|     // Verify events | ||||
|     expect(receivedEvents.length).to.equal(2); | ||||
|      | ||||
|     // Check started event | ||||
|     expect(receivedEvents[0].type).to.equal('started'); | ||||
|     expect(receivedEvents[0].data.name).to.equal('TestComponent'); | ||||
|     expect(receivedEvents[0].data.version).to.equal('1.0.0'); | ||||
|      | ||||
|     // Check stopped event | ||||
|     expect(receivedEvents[1].type).to.equal('stopped'); | ||||
|     expect(receivedEvents[1].data.name).to.equal('TestComponent'); | ||||
|   // Emit events | ||||
|   eventSystem.emitCertificateIssued({ | ||||
|     domain: 'example.com', | ||||
|     certificate: 'cert-content', | ||||
|     privateKey: 'key-content', | ||||
|     expiryDate: new Date('2025-01-01') | ||||
|   }); | ||||
|    | ||||
|   expect.it('should emit connection events', async () => { | ||||
|     // Set up listeners | ||||
|     eventSystem.on(ProxyEvents.CONNECTION_ESTABLISHED, (data) => { | ||||
|       receivedEvents.push({ | ||||
|         type: 'established', | ||||
|         data | ||||
|       }); | ||||
|     }); | ||||
|      | ||||
|     eventSystem.on(ProxyEvents.CONNECTION_CLOSED, (data) => { | ||||
|       receivedEvents.push({ | ||||
|         type: 'closed', | ||||
|         data | ||||
|       }); | ||||
|     }); | ||||
|      | ||||
|     // Emit events | ||||
|     eventSystem.emitConnectionEstablished({ | ||||
|       connectionId: 'conn-123', | ||||
|       clientIp: '192.168.1.1', | ||||
|       port: 443, | ||||
|       isTls: true, | ||||
|       domain: 'example.com' | ||||
|     }); | ||||
|      | ||||
|     eventSystem.emitConnectionClosed({ | ||||
|       connectionId: 'conn-123', | ||||
|       clientIp: '192.168.1.1', | ||||
|       port: 443 | ||||
|     }); | ||||
|      | ||||
|     // Verify events | ||||
|     expect(receivedEvents.length).to.equal(2); | ||||
|      | ||||
|     // Check established event | ||||
|     expect(receivedEvents[0].type).to.equal('established'); | ||||
|     expect(receivedEvents[0].data.connectionId).to.equal('conn-123'); | ||||
|     expect(receivedEvents[0].data.clientIp).to.equal('192.168.1.1'); | ||||
|     expect(receivedEvents[0].data.port).to.equal(443); | ||||
|     expect(receivedEvents[0].data.isTls).to.be.true; | ||||
|      | ||||
|     // Check closed event | ||||
|     expect(receivedEvents[1].type).to.equal('closed'); | ||||
|     expect(receivedEvents[1].data.connectionId).to.equal('conn-123'); | ||||
|   eventSystem.emitCertificateRenewed({ | ||||
|     domain: 'example.com', | ||||
|     certificate: 'new-cert-content', | ||||
|     privateKey: 'new-key-content', | ||||
|     expiryDate: new Date('2026-01-01'), | ||||
|     isRenewal: true | ||||
|   }); | ||||
|    | ||||
|   expect.it('should support once and off subscription methods', async () => { | ||||
|     // Set up a listener that should fire only once | ||||
|     eventSystem.once(ProxyEvents.CONNECTION_ESTABLISHED, (data) => { | ||||
|       receivedEvents.push({ | ||||
|         type: 'once', | ||||
|         data | ||||
|       }); | ||||
|   // Verify events | ||||
|   expect(receivedEvents.length).toEqual(2); | ||||
|    | ||||
|   // Check issuance event | ||||
|   expect(receivedEvents[0].type).toEqual('issued'); | ||||
|   expect(receivedEvents[0].data.domain).toEqual('example.com'); | ||||
|   expect(receivedEvents[0].data.certificate).toEqual('cert-content'); | ||||
|   expect(receivedEvents[0].data.componentType).toEqual(ComponentType.SMART_PROXY); | ||||
|   expect(receivedEvents[0].data.componentId).toEqual('test-id'); | ||||
|   expect(typeof receivedEvents[0].data.timestamp).toEqual('number'); | ||||
|    | ||||
|   // Check renewal event | ||||
|   expect(receivedEvents[1].type).toEqual('renewed'); | ||||
|   expect(receivedEvents[1].data.domain).toEqual('example.com'); | ||||
|   expect(receivedEvents[1].data.isRenewal).toEqual(true); | ||||
|   expect(receivedEvents[1].data.expiryDate).toEqual(new Date('2026-01-01')); | ||||
| }); | ||||
|  | ||||
| tap.test('Event System - component lifecycle events', async () => { | ||||
|   const { eventSystem, receivedEvents } = setupEventSystem(); | ||||
|    | ||||
|   // Set up listeners | ||||
|   eventSystem.on(ProxyEvents.COMPONENT_STARTED, (data) => { | ||||
|     receivedEvents.push({ | ||||
|       type: 'started', | ||||
|       data | ||||
|     }); | ||||
|      | ||||
|     // Set up a persistent listener | ||||
|     const persistentHandler = (data: any) => { | ||||
|       receivedEvents.push({ | ||||
|         type: 'persistent', | ||||
|         data | ||||
|       }); | ||||
|     }; | ||||
|      | ||||
|     eventSystem.on(ProxyEvents.CONNECTION_ESTABLISHED, persistentHandler); | ||||
|      | ||||
|     // First event should trigger both listeners | ||||
|     eventSystem.emitConnectionEstablished({ | ||||
|       connectionId: 'conn-1', | ||||
|       clientIp: '192.168.1.1', | ||||
|       port: 443 | ||||
|     }); | ||||
|      | ||||
|     // Second event should only trigger the persistent listener | ||||
|     eventSystem.emitConnectionEstablished({ | ||||
|       connectionId: 'conn-2', | ||||
|       clientIp: '192.168.1.1', | ||||
|       port: 443 | ||||
|     }); | ||||
|      | ||||
|     // Unsubscribe the persistent listener | ||||
|     eventSystem.off(ProxyEvents.CONNECTION_ESTABLISHED, persistentHandler); | ||||
|      | ||||
|     // Third event should not trigger any listeners | ||||
|     eventSystem.emitConnectionEstablished({ | ||||
|       connectionId: 'conn-3', | ||||
|       clientIp: '192.168.1.1', | ||||
|       port: 443 | ||||
|     }); | ||||
|      | ||||
|     // Verify events | ||||
|     expect(receivedEvents.length).to.equal(3); | ||||
|     expect(receivedEvents[0].type).to.equal('once'); | ||||
|     expect(receivedEvents[0].data.connectionId).to.equal('conn-1'); | ||||
|      | ||||
|     expect(receivedEvents[1].type).to.equal('persistent'); | ||||
|     expect(receivedEvents[1].data.connectionId).to.equal('conn-1'); | ||||
|      | ||||
|     expect(receivedEvents[2].type).to.equal('persistent'); | ||||
|     expect(receivedEvents[2].data.connectionId).to.equal('conn-2'); | ||||
|   }); | ||||
| }); | ||||
|    | ||||
|   eventSystem.on(ProxyEvents.COMPONENT_STOPPED, (data) => { | ||||
|     receivedEvents.push({ | ||||
|       type: 'stopped', | ||||
|       data | ||||
|     }); | ||||
|   }); | ||||
|    | ||||
|   // Emit events | ||||
|   eventSystem.emitComponentStarted('TestComponent', '1.0.0'); | ||||
|   eventSystem.emitComponentStopped('TestComponent'); | ||||
|    | ||||
|   // Verify events | ||||
|   expect(receivedEvents.length).toEqual(2); | ||||
|    | ||||
|   // Check started event | ||||
|   expect(receivedEvents[0].type).toEqual('started'); | ||||
|   expect(receivedEvents[0].data.name).toEqual('TestComponent'); | ||||
|   expect(receivedEvents[0].data.version).toEqual('1.0.0'); | ||||
|    | ||||
|   // Check stopped event | ||||
|   expect(receivedEvents[1].type).toEqual('stopped'); | ||||
|   expect(receivedEvents[1].data.name).toEqual('TestComponent'); | ||||
| }); | ||||
|  | ||||
| tap.test('Event System - connection events', async () => { | ||||
|   const { eventSystem, receivedEvents } = setupEventSystem(); | ||||
|    | ||||
|   // Set up listeners | ||||
|   eventSystem.on(ProxyEvents.CONNECTION_ESTABLISHED, (data) => { | ||||
|     receivedEvents.push({ | ||||
|       type: 'established', | ||||
|       data | ||||
|     }); | ||||
|   }); | ||||
|    | ||||
|   eventSystem.on(ProxyEvents.CONNECTION_CLOSED, (data) => { | ||||
|     receivedEvents.push({ | ||||
|       type: 'closed', | ||||
|       data | ||||
|     }); | ||||
|   }); | ||||
|    | ||||
|   // Emit events | ||||
|   eventSystem.emitConnectionEstablished({ | ||||
|     connectionId: 'conn-123', | ||||
|     clientIp: '192.168.1.1', | ||||
|     port: 443, | ||||
|     isTls: true, | ||||
|     domain: 'example.com' | ||||
|   }); | ||||
|    | ||||
|   eventSystem.emitConnectionClosed({ | ||||
|     connectionId: 'conn-123', | ||||
|     clientIp: '192.168.1.1', | ||||
|     port: 443 | ||||
|   }); | ||||
|    | ||||
|   // Verify events | ||||
|   expect(receivedEvents.length).toEqual(2); | ||||
|    | ||||
|   // Check established event | ||||
|   expect(receivedEvents[0].type).toEqual('established'); | ||||
|   expect(receivedEvents[0].data.connectionId).toEqual('conn-123'); | ||||
|   expect(receivedEvents[0].data.clientIp).toEqual('192.168.1.1'); | ||||
|   expect(receivedEvents[0].data.port).toEqual(443); | ||||
|   expect(receivedEvents[0].data.isTls).toEqual(true); | ||||
|    | ||||
|   // Check closed event | ||||
|   expect(receivedEvents[1].type).toEqual('closed'); | ||||
|   expect(receivedEvents[1].data.connectionId).toEqual('conn-123'); | ||||
| }); | ||||
|  | ||||
| tap.test('Event System - once and off subscription methods', async () => { | ||||
|   const { eventSystem, receivedEvents } = setupEventSystem(); | ||||
|    | ||||
|   // Set up a listener that should fire only once | ||||
|   eventSystem.once(ProxyEvents.CONNECTION_ESTABLISHED, (data) => { | ||||
|     receivedEvents.push({ | ||||
|       type: 'once', | ||||
|       data | ||||
|     }); | ||||
|   }); | ||||
|    | ||||
|   // Set up a persistent listener | ||||
|   const persistentHandler = (data: any) => { | ||||
|     receivedEvents.push({ | ||||
|       type: 'persistent', | ||||
|       data | ||||
|     }); | ||||
|   }; | ||||
|    | ||||
|   eventSystem.on(ProxyEvents.CONNECTION_ESTABLISHED, persistentHandler); | ||||
|    | ||||
|   // First event should trigger both listeners | ||||
|   eventSystem.emitConnectionEstablished({ | ||||
|     connectionId: 'conn-1', | ||||
|     clientIp: '192.168.1.1', | ||||
|     port: 443 | ||||
|   }); | ||||
|    | ||||
|   // Second event should only trigger the persistent listener | ||||
|   eventSystem.emitConnectionEstablished({ | ||||
|     connectionId: 'conn-2', | ||||
|     clientIp: '192.168.1.1', | ||||
|     port: 443 | ||||
|   }); | ||||
|    | ||||
|   // Unsubscribe the persistent listener | ||||
|   eventSystem.off(ProxyEvents.CONNECTION_ESTABLISHED, persistentHandler); | ||||
|    | ||||
|   // Third event should not trigger any listeners | ||||
|   eventSystem.emitConnectionEstablished({ | ||||
|     connectionId: 'conn-3', | ||||
|     clientIp: '192.168.1.1', | ||||
|     port: 443 | ||||
|   }); | ||||
|    | ||||
|   // Verify events | ||||
|   expect(receivedEvents.length).toEqual(3); | ||||
|   expect(receivedEvents[0].type).toEqual('once'); | ||||
|   expect(receivedEvents[0].data.connectionId).toEqual('conn-1'); | ||||
|    | ||||
|   expect(receivedEvents[1].type).toEqual('persistent'); | ||||
|   expect(receivedEvents[1].data.connectionId).toEqual('conn-1'); | ||||
|    | ||||
|   expect(receivedEvents[2].type).toEqual('persistent'); | ||||
|   expect(receivedEvents[2].data.connectionId).toEqual('conn-2'); | ||||
| }); | ||||
|  | ||||
| export default tap.start(); | ||||
| @@ -1,116 +1,110 @@ | ||||
| import { expect } from '@push.rocks/tapbundle'; | ||||
| import { expect, tap } from '@push.rocks/tapbundle'; | ||||
| import * as routeUtils from '../../../ts/core/utils/route-utils.js'; | ||||
|  | ||||
| // Test domain matching | ||||
| expect.describe('Route Utils - Domain Matching', async () => { | ||||
|   expect.it('should match exact domains', async () => { | ||||
|     expect(routeUtils.matchDomain('example.com', 'example.com')).to.be.true; | ||||
|   }); | ||||
| tap.test('Route Utils - Domain Matching - exact domains', async () => { | ||||
|   expect(routeUtils.matchDomain('example.com', 'example.com')).toEqual(true); | ||||
| }); | ||||
|  | ||||
|   expect.it('should match wildcard domains', async () => { | ||||
|     expect(routeUtils.matchDomain('*.example.com', 'sub.example.com')).to.be.true; | ||||
|     expect(routeUtils.matchDomain('*.example.com', 'another.sub.example.com')).to.be.true; | ||||
|     expect(routeUtils.matchDomain('*.example.com', 'example.com')).to.be.false; | ||||
|   }); | ||||
| tap.test('Route Utils - Domain Matching - wildcard domains', async () => { | ||||
|   expect(routeUtils.matchDomain('*.example.com', 'sub.example.com')).toEqual(true); | ||||
|   expect(routeUtils.matchDomain('*.example.com', 'another.sub.example.com')).toEqual(true); | ||||
|   expect(routeUtils.matchDomain('*.example.com', 'example.com')).toEqual(false); | ||||
| }); | ||||
|  | ||||
|   expect.it('should match domains case-insensitively', async () => { | ||||
|     expect(routeUtils.matchDomain('example.com', 'EXAMPLE.com')).to.be.true; | ||||
|   }); | ||||
| tap.test('Route Utils - Domain Matching - case insensitivity', async () => { | ||||
|   expect(routeUtils.matchDomain('example.com', 'EXAMPLE.com')).toEqual(true); | ||||
| }); | ||||
|  | ||||
|   expect.it('should match routes with multiple domain patterns', async () => { | ||||
|     expect(routeUtils.matchRouteDomain(['example.com', '*.test.com'], 'example.com')).to.be.true; | ||||
|     expect(routeUtils.matchRouteDomain(['example.com', '*.test.com'], 'sub.test.com')).to.be.true; | ||||
|     expect(routeUtils.matchRouteDomain(['example.com', '*.test.com'], 'something.else')).to.be.false; | ||||
|   }); | ||||
| tap.test('Route Utils - Domain Matching - multiple domain patterns', async () => { | ||||
|   expect(routeUtils.matchRouteDomain(['example.com', '*.test.com'], 'example.com')).toEqual(true); | ||||
|   expect(routeUtils.matchRouteDomain(['example.com', '*.test.com'], 'sub.test.com')).toEqual(true); | ||||
|   expect(routeUtils.matchRouteDomain(['example.com', '*.test.com'], 'something.else')).toEqual(false); | ||||
| }); | ||||
|  | ||||
| // Test path matching | ||||
| expect.describe('Route Utils - Path Matching', async () => { | ||||
|   expect.it('should match exact paths', async () => { | ||||
|     expect(routeUtils.matchPath('/api/users', '/api/users')).to.be.true; | ||||
|   }); | ||||
| tap.test('Route Utils - Path Matching - exact paths', async () => { | ||||
|   expect(routeUtils.matchPath('/api/users', '/api/users')).toEqual(true); | ||||
| }); | ||||
|  | ||||
|   expect.it('should match wildcard paths', async () => { | ||||
|     expect(routeUtils.matchPath('/api/*', '/api/users')).to.be.true; | ||||
|     expect(routeUtils.matchPath('/api/*', '/api/products')).to.be.true; | ||||
|     expect(routeUtils.matchPath('/api/*', '/something/else')).to.be.false; | ||||
|   }); | ||||
| tap.test('Route Utils - Path Matching - wildcard paths', async () => { | ||||
|   expect(routeUtils.matchPath('/api/*', '/api/users')).toEqual(true); | ||||
|   expect(routeUtils.matchPath('/api/*', '/api/products')).toEqual(true); | ||||
|   expect(routeUtils.matchPath('/api/*', '/something/else')).toEqual(false); | ||||
| }); | ||||
|  | ||||
|   expect.it('should match complex wildcard patterns', async () => { | ||||
|     expect(routeUtils.matchPath('/api/*/details', '/api/users/details')).to.be.true; | ||||
|     expect(routeUtils.matchPath('/api/*/details', '/api/products/details')).to.be.true; | ||||
|     expect(routeUtils.matchPath('/api/*/details', '/api/users/other')).to.be.false; | ||||
|   }); | ||||
| tap.test('Route Utils - Path Matching - complex wildcard patterns', async () => { | ||||
|   expect(routeUtils.matchPath('/api/*/details', '/api/users/details')).toEqual(true); | ||||
|   expect(routeUtils.matchPath('/api/*/details', '/api/products/details')).toEqual(true); | ||||
|   expect(routeUtils.matchPath('/api/*/details', '/api/users/other')).toEqual(false); | ||||
| }); | ||||
|  | ||||
| // Test IP matching | ||||
| expect.describe('Route Utils - IP Matching', async () => { | ||||
|   expect.it('should match exact IPs', async () => { | ||||
|     expect(routeUtils.matchIpPattern('192.168.1.1', '192.168.1.1')).to.be.true; | ||||
|   }); | ||||
| tap.test('Route Utils - IP Matching - exact IPs', async () => { | ||||
|   expect(routeUtils.matchIpPattern('192.168.1.1', '192.168.1.1')).toEqual(true); | ||||
| }); | ||||
|  | ||||
|   expect.it('should match wildcard IPs', async () => { | ||||
|     expect(routeUtils.matchIpPattern('192.168.1.*', '192.168.1.100')).to.be.true; | ||||
|     expect(routeUtils.matchIpPattern('192.168.1.*', '192.168.2.1')).to.be.false; | ||||
|   }); | ||||
| tap.test('Route Utils - IP Matching - wildcard IPs', async () => { | ||||
|   expect(routeUtils.matchIpPattern('192.168.1.*', '192.168.1.100')).toEqual(true); | ||||
|   expect(routeUtils.matchIpPattern('192.168.1.*', '192.168.2.1')).toEqual(false); | ||||
| }); | ||||
|  | ||||
|   expect.it('should match CIDR notation', async () => { | ||||
|     expect(routeUtils.matchIpPattern('192.168.1.0/24', '192.168.1.100')).to.be.true; | ||||
|     expect(routeUtils.matchIpPattern('192.168.1.0/24', '192.168.2.1')).to.be.false; | ||||
|   }); | ||||
| tap.test('Route Utils - IP Matching - CIDR notation', async () => { | ||||
|   expect(routeUtils.matchIpPattern('192.168.1.0/24', '192.168.1.100')).toEqual(true); | ||||
|   expect(routeUtils.matchIpPattern('192.168.1.0/24', '192.168.2.1')).toEqual(false); | ||||
| }); | ||||
|  | ||||
|   expect.it('should handle IPv6-mapped IPv4 addresses', async () => { | ||||
|     expect(routeUtils.matchIpPattern('192.168.1.1', '::ffff:192.168.1.1')).to.be.true; | ||||
|   }); | ||||
| tap.test('Route Utils - IP Matching - IPv6-mapped IPv4 addresses', async () => { | ||||
|   expect(routeUtils.matchIpPattern('192.168.1.1', '::ffff:192.168.1.1')).toEqual(true); | ||||
| }); | ||||
|  | ||||
|   expect.it('should correctly authorize IPs based on allow/block lists', async () => { | ||||
|     // With allow and block lists | ||||
|     expect(routeUtils.isIpAuthorized('192.168.1.1', ['192.168.1.*'], ['192.168.1.5'])).to.be.true; | ||||
|     expect(routeUtils.isIpAuthorized('192.168.1.5', ['192.168.1.*'], ['192.168.1.5'])).to.be.false; | ||||
|      | ||||
|     // With only allow list | ||||
|     expect(routeUtils.isIpAuthorized('192.168.1.1', ['192.168.1.*'])).to.be.true; | ||||
|     expect(routeUtils.isIpAuthorized('192.168.2.1', ['192.168.1.*'])).to.be.false; | ||||
|      | ||||
|     // With only block list | ||||
|     expect(routeUtils.isIpAuthorized('192.168.1.5', undefined, ['192.168.1.5'])).to.be.false; | ||||
|     expect(routeUtils.isIpAuthorized('192.168.1.1', undefined, ['192.168.1.5'])).to.be.true; | ||||
|      | ||||
|     // With wildcard in allow list | ||||
|     expect(routeUtils.isIpAuthorized('192.168.1.1', ['*'], ['192.168.1.5'])).to.be.true; | ||||
|   }); | ||||
| tap.test('Route Utils - IP Matching - IP authorization with allow/block lists', async () => { | ||||
|   // With allow and block lists | ||||
|   expect(routeUtils.isIpAuthorized('192.168.1.1', ['192.168.1.*'], ['192.168.1.5'])).toEqual(true); | ||||
|   expect(routeUtils.isIpAuthorized('192.168.1.5', ['192.168.1.*'], ['192.168.1.5'])).toEqual(false); | ||||
|    | ||||
|   // With only allow list | ||||
|   expect(routeUtils.isIpAuthorized('192.168.1.1', ['192.168.1.*'])).toEqual(true); | ||||
|   expect(routeUtils.isIpAuthorized('192.168.2.1', ['192.168.1.*'])).toEqual(false); | ||||
|    | ||||
|   // With only block list | ||||
|   expect(routeUtils.isIpAuthorized('192.168.1.5', undefined, ['192.168.1.5'])).toEqual(false); | ||||
|   expect(routeUtils.isIpAuthorized('192.168.1.1', undefined, ['192.168.1.5'])).toEqual(true); | ||||
|    | ||||
|   // With wildcard in allow list | ||||
|   expect(routeUtils.isIpAuthorized('192.168.1.1', ['*'], ['192.168.1.5'])).toEqual(true); | ||||
| }); | ||||
|  | ||||
| // Test route specificity calculation | ||||
| expect.describe('Route Utils - Route Specificity', async () => { | ||||
|   expect.it('should calculate route specificity correctly', async () => { | ||||
|     const basicRoute = { domains: 'example.com' }; | ||||
|     const pathRoute = { domains: 'example.com', path: '/api' }; | ||||
|     const wildcardPathRoute = { domains: 'example.com', path: '/api/*' }; | ||||
|     const headerRoute = { domains: 'example.com', headers: { 'content-type': 'application/json' } }; | ||||
|     const complexRoute = {  | ||||
|       domains: 'example.com',  | ||||
|       path: '/api',  | ||||
|       headers: { 'content-type': 'application/json' }, | ||||
|       clientIp: ['192.168.1.1']  | ||||
|     }; | ||||
|      | ||||
|     // Path routes should have higher specificity than domain-only routes | ||||
|     expect(routeUtils.calculateRouteSpecificity(pathRoute)) | ||||
|       .to.be.greaterThan(routeUtils.calculateRouteSpecificity(basicRoute)); | ||||
|      | ||||
|     // Exact path routes should have higher specificity than wildcard path routes | ||||
|     expect(routeUtils.calculateRouteSpecificity(pathRoute)) | ||||
|       .to.be.greaterThan(routeUtils.calculateRouteSpecificity(wildcardPathRoute)); | ||||
|      | ||||
|     // Routes with headers should have higher specificity than routes without | ||||
|     expect(routeUtils.calculateRouteSpecificity(headerRoute)) | ||||
|       .to.be.greaterThan(routeUtils.calculateRouteSpecificity(basicRoute)); | ||||
|      | ||||
|     // Complex routes should have the highest specificity | ||||
|     expect(routeUtils.calculateRouteSpecificity(complexRoute)) | ||||
|       .to.be.greaterThan(routeUtils.calculateRouteSpecificity(pathRoute)); | ||||
|     expect(routeUtils.calculateRouteSpecificity(complexRoute)) | ||||
|       .to.be.greaterThan(routeUtils.calculateRouteSpecificity(headerRoute)); | ||||
|   }); | ||||
| }); | ||||
| tap.test('Route Utils - Route Specificity - calculating correctly', async () => { | ||||
|   const basicRoute = { domains: 'example.com' }; | ||||
|   const pathRoute = { domains: 'example.com', path: '/api' }; | ||||
|   const wildcardPathRoute = { domains: 'example.com', path: '/api/*' }; | ||||
|   const headerRoute = { domains: 'example.com', headers: { 'content-type': 'application/json' } }; | ||||
|   const complexRoute = {  | ||||
|     domains: 'example.com',  | ||||
|     path: '/api',  | ||||
|     headers: { 'content-type': 'application/json' }, | ||||
|     clientIp: ['192.168.1.1']  | ||||
|   }; | ||||
|    | ||||
|   // Path routes should have higher specificity than domain-only routes | ||||
|   expect(routeUtils.calculateRouteSpecificity(pathRoute) >  | ||||
|          routeUtils.calculateRouteSpecificity(basicRoute)).toEqual(true); | ||||
|    | ||||
|   // Exact path routes should have higher specificity than wildcard path routes | ||||
|   expect(routeUtils.calculateRouteSpecificity(pathRoute) >  | ||||
|          routeUtils.calculateRouteSpecificity(wildcardPathRoute)).toEqual(true); | ||||
|    | ||||
|   // Routes with headers should have higher specificity than routes without | ||||
|   expect(routeUtils.calculateRouteSpecificity(headerRoute) >  | ||||
|          routeUtils.calculateRouteSpecificity(basicRoute)).toEqual(true); | ||||
|    | ||||
|   // Complex routes should have the highest specificity | ||||
|   expect(routeUtils.calculateRouteSpecificity(complexRoute) >  | ||||
|          routeUtils.calculateRouteSpecificity(pathRoute)).toEqual(true); | ||||
|   expect(routeUtils.calculateRouteSpecificity(complexRoute) >  | ||||
|          routeUtils.calculateRouteSpecificity(headerRoute)).toEqual(true); | ||||
| }); | ||||
|  | ||||
| export default tap.start(); | ||||
| @@ -1,24 +1,22 @@ | ||||
| import { expect, tap } from '@push.rocks/tapbundle'; | ||||
| import * as plugins from '../ts/plugins.js'; | ||||
| import { NetworkProxy } from '../ts/proxies/network-proxy/index.js'; | ||||
| import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js'; | ||||
| import type { IRouteContext } from '../ts/core/models/route-context.js'; | ||||
| import * as http from 'http'; | ||||
| import * as https from 'https'; | ||||
| import * as http2 from 'http2'; | ||||
|  | ||||
| const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); | ||||
|  | ||||
| // Declare variables for tests | ||||
| let networkProxy: NetworkProxy; | ||||
| let testServer: http.Server; | ||||
| let testServerHttp2: http2.Http2Server; | ||||
| let testServer: plugins.http.Server; | ||||
| let testServerHttp2: plugins.http2.Http2Server; | ||||
| let serverPort: number; | ||||
| let serverPortHttp2: number; | ||||
|  | ||||
| // Setup test environment | ||||
| tap.test('setup NetworkProxy function-based targets test environment', async () => { | ||||
|   // Create simple HTTP server to respond to requests | ||||
|   testServer = http.createServer((req, res) => { | ||||
|   testServer = plugins.http.createServer((req, res) => { | ||||
|     res.writeHead(200, { 'Content-Type': 'application/json' }); | ||||
|     res.end(JSON.stringify({ | ||||
|       url: req.url, | ||||
| @@ -29,7 +27,7 @@ tap.test('setup NetworkProxy function-based targets test environment', async () | ||||
|   }); | ||||
|    | ||||
|   // Create simple HTTP/2 server to respond to requests | ||||
|   testServerHttp2 = http2.createServer(); | ||||
|   testServerHttp2 = plugins.http2.createServer(); | ||||
|   testServerHttp2.on('stream', (stream, headers) => { | ||||
|     stream.respond({ | ||||
|       'content-type': 'application/json', | ||||
| @@ -82,10 +80,10 @@ tap.test('should support static host/port routes', async () => { | ||||
|   const routes: IRouteConfig[] = [ | ||||
|     { | ||||
|       name: 'static-route', | ||||
|       domain: 'example.com', | ||||
|       priority: 100, | ||||
|       match: { | ||||
|         domain: 'example.com' | ||||
|         domains: 'example.com', | ||||
|         ports: 0 | ||||
|       }, | ||||
|       action: { | ||||
|         type: 'forward', | ||||
| @@ -124,10 +122,10 @@ tap.test('should support function-based host', async () => { | ||||
|   const routes: IRouteConfig[] = [ | ||||
|     { | ||||
|       name: 'function-host-route', | ||||
|       domain: 'function.example.com', | ||||
|       priority: 100, | ||||
|       match: { | ||||
|         domain: 'function.example.com' | ||||
|         domains: 'function.example.com', | ||||
|         ports: 0 | ||||
|       }, | ||||
|       action: { | ||||
|         type: 'forward', | ||||
| @@ -169,10 +167,10 @@ tap.test('should support function-based port', async () => { | ||||
|   const routes: IRouteConfig[] = [ | ||||
|     { | ||||
|       name: 'function-port-route', | ||||
|       domain: 'function-port.example.com', | ||||
|       priority: 100, | ||||
|       match: { | ||||
|         domain: 'function-port.example.com' | ||||
|         domains: 'function-port.example.com', | ||||
|         ports: 0 | ||||
|       }, | ||||
|       action: { | ||||
|         type: 'forward', | ||||
| @@ -214,10 +212,10 @@ tap.test('should support function-based host AND port', async () => { | ||||
|   const routes: IRouteConfig[] = [ | ||||
|     { | ||||
|       name: 'function-both-route', | ||||
|       domain: 'function-both.example.com', | ||||
|       priority: 100, | ||||
|       match: { | ||||
|         domain: 'function-both.example.com' | ||||
|         domains: 'function-both.example.com', | ||||
|         ports: 0 | ||||
|       }, | ||||
|       action: { | ||||
|         type: 'forward', | ||||
| @@ -260,10 +258,10 @@ tap.test('should support context-based routing with path', async () => { | ||||
|   const routes: IRouteConfig[] = [ | ||||
|     { | ||||
|       name: 'context-path-route', | ||||
|       domain: 'context.example.com', | ||||
|       priority: 100, | ||||
|       match: { | ||||
|         domain: 'context.example.com' | ||||
|         domains: 'context.example.com', | ||||
|         ports: 0 | ||||
|       }, | ||||
|       action: { | ||||
|         type: 'forward', | ||||
| @@ -338,10 +336,10 @@ tap.test('cleanup NetworkProxy function-based targets test environment', async ( | ||||
| }); | ||||
|  | ||||
| // Helper function to make HTTPS requests with self-signed certificate support | ||||
| async function makeRequest(options: http.RequestOptions): Promise<{ statusCode: number, headers: http.IncomingHttpHeaders, body: string }> { | ||||
| async function makeRequest(options: plugins.http.RequestOptions): Promise<{ statusCode: number, headers: plugins.http.IncomingHttpHeaders, body: string }> { | ||||
|   return new Promise((resolve, reject) => { | ||||
|     // Use HTTPS with rejectUnauthorized: false to accept self-signed certificates | ||||
|     const req = https.request({ | ||||
|     const req = plugins.https.request({ | ||||
|       ...options, | ||||
|       rejectUnauthorized: false, // Accept self-signed certificates | ||||
|     }, (res) => { | ||||
|   | ||||
| @@ -3,6 +3,6 @@ | ||||
|  */ | ||||
| export const commitinfo = { | ||||
|   name: '@push.rocks/smartproxy', | ||||
|   version: '16.0.2', | ||||
|   version: '16.0.3', | ||||
|   description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' | ||||
| } | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| import * as plugins from '../../plugins.js'; | ||||
|  | ||||
| /** | ||||
|  * Shared Route Context Interface | ||||
|  *  | ||||
| @@ -42,8 +44,8 @@ export interface IRouteContext { | ||||
|  * Used only in NetworkProxy for HTTP request handling | ||||
|  */ | ||||
| export interface IHttpRouteContext extends IRouteContext { | ||||
|   req?: any; // http.IncomingMessage  | ||||
|   res?: any; // http.ServerResponse | ||||
|   req?: plugins.http.IncomingMessage; | ||||
|   res?: plugins.http.ServerResponse; | ||||
|   method?: string; // HTTP method (GET, POST, etc.) | ||||
| } | ||||
|  | ||||
| @@ -52,7 +54,7 @@ export interface IHttpRouteContext extends IRouteContext { | ||||
|  * Used only in NetworkProxy for HTTP/2 request handling | ||||
|  */ | ||||
| export interface IHttp2RouteContext extends IHttpRouteContext { | ||||
|   stream?: any; // http2.Http2Stream | ||||
|   stream?: plugins.http2.ServerHttp2Stream; | ||||
|   headers?: Record<string, string>; // HTTP/2 pseudo-headers like :method, :path | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -13,8 +13,8 @@ | ||||
|  * @returns Whether the domain matches the pattern | ||||
|  */ | ||||
| export function matchDomain(pattern: string, domain: string): boolean { | ||||
|   // Handle exact match | ||||
|   if (pattern === domain) { | ||||
|   // Handle exact match (case-insensitive) | ||||
|   if (pattern.toLowerCase() === domain.toLowerCase()) { | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
| @@ -139,9 +139,13 @@ export function matchIpCidr(cidr: string, ip: string): boolean { | ||||
|   try { | ||||
|     const { subnet, bits } = parsed; | ||||
|      | ||||
|     // Normalize IPv6-mapped IPv4 addresses | ||||
|     const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip; | ||||
|     const normalizedSubnet = subnet.startsWith('::ffff:') ? subnet.substring(7) : subnet; | ||||
|      | ||||
|     // Convert IP addresses to numeric values | ||||
|     const ipNum = ipToNumber(ip); | ||||
|     const subnetNum = ipToNumber(subnet); | ||||
|     const ipNum = ipToNumber(normalizedIp); | ||||
|     const subnetNum = ipToNumber(normalizedSubnet); | ||||
|      | ||||
|     // Calculate subnet mask | ||||
|     const maskNum = ~(2 ** (32 - bits) - 1); | ||||
| @@ -161,26 +165,41 @@ export function matchIpCidr(cidr: string, ip: string): boolean { | ||||
|  * @returns Whether the IP matches the pattern | ||||
|  */ | ||||
| export function matchIpPattern(pattern: string, ip: string): boolean { | ||||
|   // Handle exact match | ||||
|   if (pattern === ip) { | ||||
|   // Normalize IPv6-mapped IPv4 addresses | ||||
|   const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip; | ||||
|   const normalizedPattern = pattern.startsWith('::ffff:') ? pattern.substring(7) : pattern; | ||||
|    | ||||
|   // Handle exact match with all variations | ||||
|   if (pattern === ip || normalizedPattern === normalizedIp ||  | ||||
|       pattern === normalizedIp || normalizedPattern === ip) { | ||||
|     return true; | ||||
|   } | ||||
|    | ||||
|   // Handle "all" wildcard | ||||
|   if (pattern === '*') { | ||||
|   if (pattern === '*' || normalizedPattern === '*') { | ||||
|     return true; | ||||
|   } | ||||
|    | ||||
|   // Handle CIDR notation (e.g., 192.168.1.0/24) | ||||
|   if (pattern.includes('/')) { | ||||
|     return matchIpCidr(pattern, ip); | ||||
|     return matchIpCidr(pattern, normalizedIp) ||  | ||||
|            (normalizedPattern !== pattern && matchIpCidr(normalizedPattern, normalizedIp)); | ||||
|   } | ||||
|    | ||||
|   // Handle glob pattern (e.g., 192.168.1.*) | ||||
|   if (pattern.includes('*')) { | ||||
|     const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*'); | ||||
|     const regex = new RegExp(`^${regexPattern}$`); | ||||
|     return regex.test(ip); | ||||
|     if (regex.test(ip) || regex.test(normalizedIp)) { | ||||
|       return true; | ||||
|     } | ||||
|      | ||||
|     // If pattern was normalized, also test with normalized pattern | ||||
|     if (normalizedPattern !== pattern) { | ||||
|       const normalizedRegexPattern = normalizedPattern.replace(/\./g, '\\.').replace(/\*/g, '.*'); | ||||
|       const normalizedRegex = new RegExp(`^${normalizedRegexPattern}$`); | ||||
|       return normalizedRegex.test(ip) || normalizedRegex.test(normalizedIp); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   return false; | ||||
|   | ||||
| @@ -291,12 +291,15 @@ export class RouteManager { | ||||
|  | ||||
|   /** | ||||
|    * Match an IP pattern against an IP | ||||
|    * Supports exact matches, wildcard patterns, and CIDR notation | ||||
|    */ | ||||
|   private matchIp(pattern: string, ip: string): boolean { | ||||
|     // Exact match | ||||
|     if (pattern === ip) { | ||||
|       return true; | ||||
|     } | ||||
|  | ||||
|     // Wildcard matching (e.g., 192.168.0.*) | ||||
|     if (pattern.includes('*')) { | ||||
|       const regexPattern = pattern | ||||
|         .replace(/\./g, '\\.') | ||||
| @@ -306,10 +309,65 @@ export class RouteManager { | ||||
|       return regex.test(ip); | ||||
|     } | ||||
|  | ||||
|     // TODO: Implement CIDR matching | ||||
|     // CIDR matching (e.g., 192.168.0.0/24) | ||||
|     if (pattern.includes('/')) { | ||||
|       try { | ||||
|         const [subnet, bits] = pattern.split('/'); | ||||
|          | ||||
|         // Convert IP addresses to numeric format for comparison | ||||
|         const ipBinary = this.ipToBinary(ip); | ||||
|         const subnetBinary = this.ipToBinary(subnet); | ||||
|          | ||||
|         if (!ipBinary || !subnetBinary) { | ||||
|           return false; | ||||
|         } | ||||
|          | ||||
|         // Get the subnet mask from CIDR notation | ||||
|         const mask = parseInt(bits, 10); | ||||
|         if (isNaN(mask) || mask < 0 || mask > 32) { | ||||
|           return false; | ||||
|         } | ||||
|          | ||||
|         // Check if the first 'mask' bits match between IP and subnet | ||||
|         return ipBinary.slice(0, mask) === subnetBinary.slice(0, mask); | ||||
|       } catch (error) { | ||||
|         // If we encounter any error during CIDR matching, return false | ||||
|         return false; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return false; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Convert an IP address to its binary representation | ||||
|    * @param ip The IP address to convert | ||||
|    * @returns Binary string representation or null if invalid | ||||
|    */ | ||||
|   private ipToBinary(ip: string): string | null { | ||||
|     // Handle IPv4 addresses only for now | ||||
|     const parts = ip.split('.'); | ||||
|      | ||||
|     // Validate IP format | ||||
|     if (parts.length !== 4) { | ||||
|       return null; | ||||
|     } | ||||
|      | ||||
|     // Convert each octet to 8-bit binary and concatenate | ||||
|     try { | ||||
|       return parts | ||||
|         .map(part => { | ||||
|           const num = parseInt(part, 10); | ||||
|           if (isNaN(num) || num < 0 || num > 255) { | ||||
|             throw new Error('Invalid IP octet'); | ||||
|           } | ||||
|           return num.toString(2).padStart(8, '0'); | ||||
|         }) | ||||
|         .join(''); | ||||
|     } catch (error) { | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -500,68 +500,8 @@ export class NetworkProxy implements IMetricsTracker { | ||||
|     this.logger.info(`Route configuration updated with ${routes.length} routes and ${legacyConfigs.length} proxy configs`); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * @deprecated Use updateRouteConfigs instead | ||||
|    * Legacy method for updating proxy configurations using IReverseProxyConfig | ||||
|    * This method is maintained for backward compatibility | ||||
|    */ | ||||
|   public async updateProxyConfigs( | ||||
|     proxyConfigsArg: IReverseProxyConfig[] | ||||
|   ): Promise<void> { | ||||
|     this.logger.info(`Converting ${proxyConfigsArg.length} legacy configs to route configs`); | ||||
|  | ||||
|     // Convert legacy configs to route configs | ||||
|     const routes: IRouteConfig[] = proxyConfigsArg.map(config => | ||||
|       convertLegacyConfigToRouteConfig(config, this.options.port) | ||||
|     ); | ||||
|  | ||||
|     // Use the primary method | ||||
|     return this.updateRouteConfigs(routes); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * @deprecated Use route-based configuration instead | ||||
|    * Converts SmartProxy domain configurations to NetworkProxy configs | ||||
|    * This method is maintained for backward compatibility | ||||
|    */ | ||||
|   public convertSmartProxyConfigs( | ||||
|     domainConfigs: Array<{ | ||||
|       domains: string[]; | ||||
|       targetIPs?: string[]; | ||||
|       allowedIPs?: string[]; | ||||
|     }>, | ||||
|     sslKeyPair?: { key: string; cert: string } | ||||
|   ): IReverseProxyConfig[] { | ||||
|     this.logger.warn('convertSmartProxyConfigs is deprecated - use route-based configuration instead'); | ||||
|  | ||||
|     const proxyConfigs: IReverseProxyConfig[] = []; | ||||
|  | ||||
|     // Use default certificates if not provided | ||||
|     const defaultCerts = this.certificateManager.getDefaultCertificates(); | ||||
|     const sslKey = sslKeyPair?.key || defaultCerts.key; | ||||
|     const sslCert = sslKeyPair?.cert || defaultCerts.cert; | ||||
|  | ||||
|     for (const domainConfig of domainConfigs) { | ||||
|       // Each domain in the domains array gets its own config | ||||
|       for (const domain of domainConfig.domains) { | ||||
|         // Skip non-hostname patterns (like IP addresses) | ||||
|         if (domain.match(/^\d+\.\d+\.\d+\.\d+$/) || domain === '*' || domain === 'localhost') { | ||||
|           continue; | ||||
|         } | ||||
|  | ||||
|         proxyConfigs.push({ | ||||
|           hostName: domain, | ||||
|           destinationIps: domainConfig.targetIPs || ['localhost'], | ||||
|           destinationPorts: [this.options.port], // Use the NetworkProxy port | ||||
|           privateKey: sslKey, | ||||
|           publicKey: sslCert | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     this.logger.info(`Converted ${domainConfigs.length} SmartProxy configs to ${proxyConfigs.length} NetworkProxy configs`); | ||||
|     return proxyConfigs; | ||||
|   } | ||||
|   // Legacy methods have been removed. | ||||
|   // Please use updateRouteConfigs() directly with modern route-based configuration. | ||||
|  | ||||
|   /** | ||||
|    * Adds default headers to be included in all responses | ||||
| @@ -650,62 +590,4 @@ export class NetworkProxy implements IMetricsTracker { | ||||
|   public getRouteConfigs(): IRouteConfig[] { | ||||
|     return this.routeManager.getRoutes(); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * @deprecated Use getRouteConfigs instead | ||||
|    * Gets all proxy configurations currently in use in the legacy format | ||||
|    * This method is maintained for backward compatibility | ||||
|    */ | ||||
|   public getProxyConfigs(): IReverseProxyConfig[] { | ||||
|     this.logger.warn('getProxyConfigs is deprecated - use getRouteConfigs instead'); | ||||
|  | ||||
|     // Create legacy proxy configs from our route configurations | ||||
|     const legacyConfigs: IReverseProxyConfig[] = []; | ||||
|     const currentRoutes = this.routeManager.getRoutes(); | ||||
|  | ||||
|     for (const route of currentRoutes) { | ||||
|       // Skip non-forward routes or routes without domains | ||||
|       if (route.action.type !== 'forward' || !route.match.domains || !route.action.target) { | ||||
|         continue; | ||||
|       } | ||||
|  | ||||
|       // Skip routes with function-based targets | ||||
|       if (typeof route.action.target.host === 'function' || typeof route.action.target.port === 'function') { | ||||
|         continue; | ||||
|       } | ||||
|  | ||||
|       // Get domains | ||||
|       const domains = Array.isArray(route.match.domains) | ||||
|         ? route.match.domains.filter(d => !d.includes('*')) | ||||
|         : route.match.domains.includes('*') ? [] : [route.match.domains]; | ||||
|  | ||||
|       // Get certificate | ||||
|       let privateKey = ''; | ||||
|       let publicKey = ''; | ||||
|  | ||||
|       if (route.action.tls?.certificate && route.action.tls.certificate !== 'auto') { | ||||
|         privateKey = route.action.tls.certificate.key; | ||||
|         publicKey = route.action.tls.certificate.cert; | ||||
|       } else { | ||||
|         const defaultCerts = this.certificateManager.getDefaultCertificates(); | ||||
|         privateKey = defaultCerts.key; | ||||
|         publicKey = defaultCerts.cert; | ||||
|       } | ||||
|  | ||||
|       // Create legacy config for each domain | ||||
|       for (const domain of domains) { | ||||
|         legacyConfigs.push({ | ||||
|           hostName: domain, | ||||
|           destinationIps: Array.isArray(route.action.target.host) | ||||
|             ? route.action.target.host | ||||
|             : [route.action.target.host], | ||||
|           destinationPorts: [route.action.target.port], | ||||
|           privateKey, | ||||
|           publicKey | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return legacyConfigs; | ||||
|   } | ||||
| } | ||||
| @@ -661,150 +661,6 @@ export class RequestHandler { | ||||
|       }); | ||||
|       return; | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       // Find target based on hostname | ||||
|       const proxyConfig = this.router.routeReq(req); | ||||
|        | ||||
|       if (!proxyConfig) { | ||||
|         // No matching proxy configuration | ||||
|         this.logger.warn(`No proxy configuration for host: ${req.headers.host}`); | ||||
|         res.statusCode = 404; | ||||
|         res.end('Not Found: No proxy configuration for this host'); | ||||
|          | ||||
|         // Increment failed requests counter | ||||
|         if (this.metricsTracker) { | ||||
|           this.metricsTracker.incrementFailedRequests(); | ||||
|         } | ||||
|          | ||||
|         return; | ||||
|       } | ||||
|        | ||||
|       // Get destination IP using round-robin if multiple IPs configured | ||||
|       const destination = this.connectionPool.getNextTarget( | ||||
|         proxyConfig.destinationIps,  | ||||
|         proxyConfig.destinationPorts[0] | ||||
|       ); | ||||
|        | ||||
|       // Create options for the proxy request | ||||
|       const options: plugins.http.RequestOptions = { | ||||
|         hostname: destination.host, | ||||
|         port: destination.port, | ||||
|         path: req.url, | ||||
|         method: req.method, | ||||
|         headers: { ...req.headers } | ||||
|       }; | ||||
|        | ||||
|       // Remove host header to avoid issues with virtual hosts on target server | ||||
|       // The host header should match the target server's expected hostname | ||||
|       if (options.headers && options.headers.host) { | ||||
|         if ((proxyConfig as IReverseProxyConfig).rewriteHostHeader) { | ||||
|           options.headers.host = `${destination.host}:${destination.port}`; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       this.logger.debug( | ||||
|         `Proxying request to ${destination.host}:${destination.port}${req.url}`, | ||||
|         { method: req.method } | ||||
|       ); | ||||
|        | ||||
|       // Create proxy request | ||||
|       const proxyReq = plugins.http.request(options, (proxyRes) => { | ||||
|         // Copy status code | ||||
|         res.statusCode = proxyRes.statusCode || 500; | ||||
|          | ||||
|         // Copy headers from proxy response to client response | ||||
|         for (const [key, value] of Object.entries(proxyRes.headers)) { | ||||
|           if (value !== undefined) { | ||||
|             res.setHeader(key, value); | ||||
|           } | ||||
|         } | ||||
|          | ||||
|         // Pipe proxy response to client response | ||||
|         proxyRes.pipe(res); | ||||
|          | ||||
|         // Increment served requests counter when the response finishes | ||||
|         res.on('finish', () => { | ||||
|           if (this.metricsTracker) { | ||||
|             this.metricsTracker.incrementRequestsServed(); | ||||
|           } | ||||
|            | ||||
|           // Log the completed request | ||||
|           const duration = Date.now() - startTime; | ||||
|           this.logger.debug( | ||||
|             `Request completed in ${duration}ms: ${req.method} ${req.url} ${res.statusCode}`, | ||||
|             { duration, statusCode: res.statusCode } | ||||
|           ); | ||||
|         }); | ||||
|       }); | ||||
|        | ||||
|       // Handle proxy request errors | ||||
|       proxyReq.on('error', (error) => { | ||||
|         const duration = Date.now() - startTime; | ||||
|         this.logger.error( | ||||
|           `Proxy error for ${req.method} ${req.url}: ${error.message}`, | ||||
|           { duration, error: error.message } | ||||
|         ); | ||||
|          | ||||
|         // Increment failed requests counter | ||||
|         if (this.metricsTracker) { | ||||
|           this.metricsTracker.incrementFailedRequests(); | ||||
|         } | ||||
|          | ||||
|         // Check if headers have already been sent | ||||
|         if (!res.headersSent) { | ||||
|           res.statusCode = 502; | ||||
|           res.end(`Bad Gateway: ${error.message}`); | ||||
|         } else { | ||||
|           // If headers already sent, just close the connection | ||||
|           res.end(); | ||||
|         } | ||||
|       }); | ||||
|        | ||||
|       // Pipe request body to proxy request and handle client-side errors | ||||
|       req.pipe(proxyReq); | ||||
|        | ||||
|       // Handle client disconnection | ||||
|       req.on('error', (error) => { | ||||
|         this.logger.debug(`Client connection error: ${error.message}`); | ||||
|         proxyReq.destroy(); | ||||
|          | ||||
|         // Increment failed requests counter on client errors | ||||
|         if (this.metricsTracker) { | ||||
|           this.metricsTracker.incrementFailedRequests(); | ||||
|         } | ||||
|       }); | ||||
|        | ||||
|       // Handle response errors | ||||
|       res.on('error', (error) => { | ||||
|         this.logger.debug(`Response error: ${error.message}`); | ||||
|         proxyReq.destroy(); | ||||
|          | ||||
|         // Increment failed requests counter on response errors | ||||
|         if (this.metricsTracker) { | ||||
|           this.metricsTracker.incrementFailedRequests(); | ||||
|         } | ||||
|       }); | ||||
|        | ||||
|     } catch (error) { | ||||
|       // Handle any unexpected errors | ||||
|       this.logger.error( | ||||
|         `Unexpected error handling request: ${error.message}`, | ||||
|         { error: error.stack } | ||||
|       ); | ||||
|        | ||||
|       // Increment failed requests counter | ||||
|       if (this.metricsTracker) { | ||||
|         this.metricsTracker.incrementFailedRequests(); | ||||
|       } | ||||
|        | ||||
|       if (!res.headersSent) { | ||||
|         res.statusCode = 500; | ||||
|         res.end('Internal Server Error'); | ||||
|       } else { | ||||
|         res.end(); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|   | ||||
| @@ -56,7 +56,7 @@ export interface IRouteContext { | ||||
|   routeId?: string;      // The ID of the matched route | ||||
|  | ||||
|   // Target information (resolved from dynamic mapping) | ||||
|   targetHost?: string;   // The resolved target host | ||||
|   targetHost?: string | string[];   // The resolved target host(s) | ||||
|   targetPort?: number;   // The resolved target port | ||||
|  | ||||
|   // Additional properties | ||||
| @@ -68,8 +68,8 @@ export interface IRouteContext { | ||||
|  * Target configuration for forwarding | ||||
|  */ | ||||
| export interface IRouteTarget { | ||||
|   host: string | string[] | ((context: any) => string | string[]);  // Support static or dynamic host selection with any compatible context | ||||
|   port: number | ((context: any) => number);  // Support static or dynamic port mapping with any compatible context | ||||
|   host: string | string[] | ((context: IRouteContext) => string | string[]);  // Host or hosts with optional function for dynamic resolution | ||||
|   port: number | ((context: IRouteContext) => number);  // Port with optional function for dynamic mapping | ||||
|   preservePort?: boolean;   // Use incoming port as target port (ignored if port is a function) | ||||
| } | ||||
|  | ||||
| @@ -108,7 +108,8 @@ export interface IRouteAuthentication { | ||||
|   oauthClientId?: string; | ||||
|   oauthClientSecret?: string; | ||||
|   oauthRedirectUri?: string; | ||||
|   [key: string]: any;  // Allow additional auth-specific options | ||||
|   // Specific options for different auth types | ||||
|   options?: Record<string, unknown>; | ||||
| } | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -1,498 +0,0 @@ | ||||
| import type { | ||||
|   IRouteConfig, | ||||
|   IRouteMatch, | ||||
|   IRouteAction, | ||||
|   IRouteTarget, | ||||
|   IRouteTls, | ||||
|   IRouteRedirect, | ||||
|   IRouteSecurity, | ||||
|   IRouteAdvanced, | ||||
|   TPortRange | ||||
| } from './models/route-types.js'; | ||||
|  | ||||
| /** | ||||
|  * Basic helper function to create a route configuration | ||||
|  */ | ||||
| export function createRoute( | ||||
|   match: IRouteMatch, | ||||
|   action: IRouteAction, | ||||
|   metadata?: { | ||||
|     name?: string; | ||||
|     description?: string; | ||||
|     priority?: number; | ||||
|     tags?: string[]; | ||||
|   } | ||||
| ): IRouteConfig { | ||||
|   return { | ||||
|     match, | ||||
|     action, | ||||
|     ...metadata | ||||
|   }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Create a basic HTTP route configuration | ||||
|  */ | ||||
| export function createHttpRoute( | ||||
|   options: { | ||||
|     ports?: number | number[]; // Default: 80 | ||||
|     domains?: string | string[]; | ||||
|     path?: string; | ||||
|     target: IRouteTarget; | ||||
|     headers?: Record<string, string>; | ||||
|     security?: IRouteSecurity; | ||||
|     name?: string; | ||||
|     description?: string; | ||||
|     priority?: number; | ||||
|     tags?: string[]; | ||||
|   } | ||||
| ): IRouteConfig { | ||||
|   return createRoute( | ||||
|     { | ||||
|       ports: options.ports || 80, | ||||
|       ...(options.domains ? { domains: options.domains } : {}), | ||||
|       ...(options.path ? { path: options.path } : {}) | ||||
|     }, | ||||
|     { | ||||
|       type: 'forward', | ||||
|       target: options.target, | ||||
|       ...(options.headers || options.security ? { | ||||
|         advanced: { | ||||
|           ...(options.headers ? { headers: options.headers } : {}) | ||||
|         }, | ||||
|         ...(options.security ? { security: options.security } : {}) | ||||
|       } : {}) | ||||
|     }, | ||||
|     { | ||||
|       name: options.name || 'HTTP Route', | ||||
|       description: options.description, | ||||
|       priority: options.priority, | ||||
|       tags: options.tags | ||||
|     } | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Create an HTTPS route configuration with TLS termination | ||||
|  */ | ||||
| export function createHttpsRoute( | ||||
|   options: { | ||||
|     ports?: number | number[]; // Default: 443 | ||||
|     domains: string | string[]; | ||||
|     path?: string; | ||||
|     target: IRouteTarget; | ||||
|     tlsMode?: 'terminate' | 'terminate-and-reencrypt'; | ||||
|     certificate?: 'auto' | { key: string; cert: string }; | ||||
|     headers?: Record<string, string>; | ||||
|     security?: IRouteSecurity; | ||||
|     name?: string; | ||||
|     description?: string; | ||||
|     priority?: number; | ||||
|     tags?: string[]; | ||||
|   } | ||||
| ): IRouteConfig { | ||||
|   return createRoute( | ||||
|     { | ||||
|       ports: options.ports || 443, | ||||
|       domains: options.domains, | ||||
|       ...(options.path ? { path: options.path } : {}) | ||||
|     }, | ||||
|     { | ||||
|       type: 'forward', | ||||
|       target: options.target, | ||||
|       tls: { | ||||
|         mode: options.tlsMode || 'terminate', | ||||
|         certificate: options.certificate || 'auto' | ||||
|       }, | ||||
|       ...(options.headers || options.security ? { | ||||
|         advanced: { | ||||
|           ...(options.headers ? { headers: options.headers } : {}) | ||||
|         }, | ||||
|         ...(options.security ? { security: options.security } : {}) | ||||
|       } : {}) | ||||
|     }, | ||||
|     { | ||||
|       name: options.name || 'HTTPS Route', | ||||
|       description: options.description, | ||||
|       priority: options.priority, | ||||
|       tags: options.tags | ||||
|     } | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Create an HTTPS passthrough route configuration | ||||
|  */ | ||||
| export function createPassthroughRoute( | ||||
|   options: { | ||||
|     ports?: number | number[]; // Default: 443 | ||||
|     domains?: string | string[]; | ||||
|     target: IRouteTarget; | ||||
|     security?: IRouteSecurity; | ||||
|     name?: string; | ||||
|     description?: string; | ||||
|     priority?: number; | ||||
|     tags?: string[]; | ||||
|   } | ||||
| ): IRouteConfig { | ||||
|   return createRoute( | ||||
|     { | ||||
|       ports: options.ports || 443, | ||||
|       ...(options.domains ? { domains: options.domains } : {}) | ||||
|     }, | ||||
|     { | ||||
|       type: 'forward', | ||||
|       target: options.target, | ||||
|       tls: { | ||||
|         mode: 'passthrough' | ||||
|       }, | ||||
|       ...(options.security ? { security: options.security } : {}) | ||||
|     }, | ||||
|     { | ||||
|       name: options.name || 'HTTPS Passthrough Route', | ||||
|       description: options.description, | ||||
|       priority: options.priority, | ||||
|       tags: options.tags | ||||
|     } | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Create a redirect route configuration | ||||
|  */ | ||||
| export function createRedirectRoute( | ||||
|   options: { | ||||
|     ports?: number | number[]; // Default: 80 | ||||
|     domains?: string | string[]; | ||||
|     path?: string; | ||||
|     redirectTo: string; | ||||
|     statusCode?: 301 | 302 | 307 | 308; | ||||
|     name?: string; | ||||
|     description?: string; | ||||
|     priority?: number; | ||||
|     tags?: string[]; | ||||
|   } | ||||
| ): IRouteConfig { | ||||
|   return createRoute( | ||||
|     { | ||||
|       ports: options.ports || 80, | ||||
|       ...(options.domains ? { domains: options.domains } : {}), | ||||
|       ...(options.path ? { path: options.path } : {}) | ||||
|     }, | ||||
|     { | ||||
|       type: 'redirect', | ||||
|       redirect: { | ||||
|         to: options.redirectTo, | ||||
|         status: options.statusCode || 301 | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       name: options.name || 'Redirect Route', | ||||
|       description: options.description, | ||||
|       priority: options.priority, | ||||
|       tags: options.tags | ||||
|     } | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Create an HTTP to HTTPS redirect route configuration | ||||
|  */ | ||||
| export function createHttpToHttpsRedirect( | ||||
|   options: { | ||||
|     domains: string | string[]; | ||||
|     statusCode?: 301 | 302 | 307 | 308; | ||||
|     name?: string; | ||||
|     priority?: number; | ||||
|   } | ||||
| ): IRouteConfig { | ||||
|   const domainArray = Array.isArray(options.domains) ? options.domains : [options.domains]; | ||||
|  | ||||
|   return createRedirectRoute({ | ||||
|     ports: 80, | ||||
|     domains: options.domains, | ||||
|     redirectTo: 'https://{domain}{path}', | ||||
|     statusCode: options.statusCode || 301, | ||||
|     name: options.name || `HTTP to HTTPS Redirect for ${domainArray.join(', ')}`, | ||||
|     priority: options.priority || 100 // High priority for redirects | ||||
|   }); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Create a block route configuration | ||||
|  */ | ||||
| export function createBlockRoute( | ||||
|   options: { | ||||
|     ports: number | number[]; | ||||
|     domains?: string | string[]; | ||||
|     clientIp?: string[]; | ||||
|     name?: string; | ||||
|     description?: string; | ||||
|     priority?: number; | ||||
|     tags?: string[]; | ||||
|   } | ||||
| ): IRouteConfig { | ||||
|   return createRoute( | ||||
|     { | ||||
|       ports: options.ports, | ||||
|       ...(options.domains ? { domains: options.domains } : {}), | ||||
|       ...(options.clientIp ? { clientIp: options.clientIp } : {}) | ||||
|     }, | ||||
|     { | ||||
|       type: 'block' | ||||
|     }, | ||||
|     { | ||||
|       name: options.name || 'Block Route', | ||||
|       description: options.description, | ||||
|       priority: options.priority || 1000, // Very high priority for blocks | ||||
|       tags: options.tags | ||||
|     } | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Create a load balancer route configuration | ||||
|  */ | ||||
| export function createLoadBalancerRoute( | ||||
|   options: { | ||||
|     ports?: number | number[]; // Default: 443 | ||||
|     domains: string | string[]; | ||||
|     path?: string; | ||||
|     targets: string[]; // Array of host names/IPs for load balancing | ||||
|     targetPort: number; | ||||
|     tlsMode?: 'passthrough' | 'terminate' | 'terminate-and-reencrypt'; | ||||
|     certificate?: 'auto' | { key: string; cert: string }; | ||||
|     headers?: Record<string, string>; | ||||
|     security?: IRouteSecurity; | ||||
|     name?: string; | ||||
|     description?: string; | ||||
|     tags?: string[]; | ||||
|   } | ||||
| ): IRouteConfig { | ||||
|   const useTls = options.tlsMode !== undefined; | ||||
|   const defaultPort = useTls ? 443 : 80; | ||||
|  | ||||
|   return createRoute( | ||||
|     { | ||||
|       ports: options.ports || defaultPort, | ||||
|       domains: options.domains, | ||||
|       ...(options.path ? { path: options.path } : {}) | ||||
|     }, | ||||
|     { | ||||
|       type: 'forward', | ||||
|       target: { | ||||
|         host: options.targets, | ||||
|         port: options.targetPort | ||||
|       }, | ||||
|       ...(useTls ? { | ||||
|         tls: { | ||||
|           mode: options.tlsMode!, | ||||
|           ...(options.tlsMode !== 'passthrough' && options.certificate ? { | ||||
|             certificate: options.certificate | ||||
|           } : {}) | ||||
|         } | ||||
|       } : {}), | ||||
|       ...(options.headers || options.security ? { | ||||
|         advanced: { | ||||
|           ...(options.headers ? { headers: options.headers } : {}) | ||||
|         }, | ||||
|         ...(options.security ? { security: options.security } : {}) | ||||
|       } : {}) | ||||
|     }, | ||||
|     { | ||||
|       name: options.name || 'Load Balanced Route', | ||||
|       description: options.description || `Load balancing across ${options.targets.length} backends`, | ||||
|       tags: options.tags | ||||
|     } | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Create a complete HTTPS server configuration with HTTP redirect | ||||
|  */ | ||||
| export function createHttpsServer( | ||||
|   options: { | ||||
|     domains: string | string[]; | ||||
|     target: IRouteTarget; | ||||
|     certificate?: 'auto' | { key: string; cert: string }; | ||||
|     security?: IRouteSecurity; | ||||
|     addHttpRedirect?: boolean; | ||||
|     name?: string; | ||||
|   } | ||||
| ): IRouteConfig[] { | ||||
|   const routes: IRouteConfig[] = []; | ||||
|   const domainArray = Array.isArray(options.domains) ? options.domains : [options.domains]; | ||||
|  | ||||
|   // Add HTTPS route | ||||
|   routes.push(createHttpsRoute({ | ||||
|     domains: options.domains, | ||||
|     target: options.target, | ||||
|     certificate: options.certificate || 'auto', | ||||
|     security: options.security, | ||||
|     name: options.name || `HTTPS Server for ${domainArray.join(', ')}` | ||||
|   })); | ||||
|  | ||||
|   // Add HTTP to HTTPS redirect if requested | ||||
|   if (options.addHttpRedirect !== false) { | ||||
|     routes.push(createHttpToHttpsRedirect({ | ||||
|       domains: options.domains, | ||||
|       name: `HTTP to HTTPS Redirect for ${domainArray.join(', ')}`, | ||||
|       priority: 100 | ||||
|     })); | ||||
|   } | ||||
|  | ||||
|   return routes; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Create a port range configuration from various input formats | ||||
|  */ | ||||
| export function createPortRange( | ||||
|   ports: number | number[] | string | Array<{ from: number; to: number }> | ||||
| ): TPortRange { | ||||
|   // If it's a string like "80,443" or "8000-9000", parse it | ||||
|   if (typeof ports === 'string') { | ||||
|     if (ports.includes('-')) { | ||||
|       // Handle range like "8000-9000" | ||||
|       const [start, end] = ports.split('-').map(p => parseInt(p.trim(), 10)); | ||||
|       return [{ from: start, to: end }]; | ||||
|     } else if (ports.includes(',')) { | ||||
|       // Handle comma-separated list like "80,443,8080" | ||||
|       return ports.split(',').map(p => parseInt(p.trim(), 10)); | ||||
|     } else { | ||||
|       // Handle single port as string | ||||
|       return parseInt(ports.trim(), 10); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Otherwise return as is | ||||
|   return ports; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Create a security configuration object | ||||
|  */ | ||||
| export function createSecurityConfig( | ||||
|   options: { | ||||
|     allowedIps?: string[]; | ||||
|     blockedIps?: string[]; | ||||
|     maxConnections?: number; | ||||
|     authentication?: { | ||||
|       type: 'basic' | 'digest' | 'oauth'; | ||||
|       // Auth-specific options | ||||
|       [key: string]: any; | ||||
|     }; | ||||
|   } | ||||
| ): IRouteSecurity { | ||||
|   return { | ||||
|     ...(options.allowedIps ? { allowedIps: options.allowedIps } : {}), | ||||
|     ...(options.blockedIps ? { blockedIps: options.blockedIps } : {}), | ||||
|     ...(options.maxConnections ? { maxConnections: options.maxConnections } : {}), | ||||
|     ...(options.authentication ? { authentication: options.authentication } : {}) | ||||
|   }; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Create a static file server route | ||||
|  */ | ||||
| export function createStaticFileRoute( | ||||
|   options: { | ||||
|     ports?: number | number[]; // Default: 80 | ||||
|     domains: string | string[]; | ||||
|     path?: string; | ||||
|     targetDirectory: string; | ||||
|     tlsMode?: 'terminate' | 'terminate-and-reencrypt'; | ||||
|     certificate?: 'auto' | { key: string; cert: string }; | ||||
|     headers?: Record<string, string>; | ||||
|     security?: IRouteSecurity; | ||||
|     name?: string; | ||||
|     description?: string; | ||||
|     priority?: number; | ||||
|     tags?: string[]; | ||||
|   } | ||||
| ): IRouteConfig { | ||||
|   const useTls = options.tlsMode !== undefined; | ||||
|   const defaultPort = useTls ? 443 : 80; | ||||
|  | ||||
|   return createRoute( | ||||
|     { | ||||
|       ports: options.ports || defaultPort, | ||||
|       domains: options.domains, | ||||
|       ...(options.path ? { path: options.path } : {}) | ||||
|     }, | ||||
|     { | ||||
|       type: 'forward', | ||||
|       target: { | ||||
|         host: 'localhost', // Static file serving is typically handled locally | ||||
|         port: 0, // Special value indicating a static file server | ||||
|         preservePort: false | ||||
|       }, | ||||
|       ...(useTls ? { | ||||
|         tls: { | ||||
|           mode: options.tlsMode!, | ||||
|           certificate: options.certificate || 'auto' | ||||
|         } | ||||
|       } : {}), | ||||
|       advanced: { | ||||
|         ...(options.headers ? { headers: options.headers } : {}), | ||||
|         staticFiles: { | ||||
|           root: options.targetDirectory, | ||||
|           index: ['index.html', 'index.htm'], | ||||
|           directory: options.targetDirectory // For backward compatibility | ||||
|         } | ||||
|       }, | ||||
|       ...(options.security ? { security: options.security } : {}) | ||||
|     }, | ||||
|     { | ||||
|       name: options.name || 'Static File Server', | ||||
|       description: options.description || `Serving static files from ${options.targetDirectory}`, | ||||
|       priority: options.priority, | ||||
|       tags: options.tags | ||||
|     } | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Create a test route for debugging purposes | ||||
|  */ | ||||
| export function createTestRoute( | ||||
|   options: { | ||||
|     ports?: number | number[]; // Default: 8000 | ||||
|     domains?: string | string[]; | ||||
|     path?: string; | ||||
|     response?: { | ||||
|       status?: number; | ||||
|       headers?: Record<string, string>; | ||||
|       body?: string; | ||||
|     }; | ||||
|     name?: string; | ||||
|   } | ||||
| ): IRouteConfig { | ||||
|   return createRoute( | ||||
|     { | ||||
|       ports: options.ports || 8000, | ||||
|       ...(options.domains ? { domains: options.domains } : {}), | ||||
|       ...(options.path ? { path: options.path } : {}) | ||||
|     }, | ||||
|     { | ||||
|       type: 'forward', | ||||
|       target: { | ||||
|         host: 'test',  // Special value indicating a test route | ||||
|         port: 0 | ||||
|       }, | ||||
|       advanced: { | ||||
|         testResponse: { | ||||
|           status: options.response?.status || 200, | ||||
|           headers: options.response?.headers || { 'Content-Type': 'text/plain' }, | ||||
|           body: options.response?.body || 'Test route is working!' | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       name: options.name || 'Test Route', | ||||
|       description: 'Route for testing and debugging', | ||||
|       priority: 500, | ||||
|       tags: ['test', 'debug'] | ||||
|     } | ||||
|   ); | ||||
| } | ||||
| @@ -1,9 +0,0 @@ | ||||
| /** | ||||
|  * Route helpers for SmartProxy | ||||
|  *  | ||||
|  * This module provides helper functions for creating various types of route configurations | ||||
|  * to be used with the SmartProxy system. | ||||
|  */ | ||||
|  | ||||
| // Re-export all functions from the route-helpers.ts file | ||||
| export * from '../route-helpers.js'; | ||||
| @@ -244,21 +244,36 @@ export class RouteManager extends plugins.EventEmitter { | ||||
|    * Match an IP against a pattern | ||||
|    */ | ||||
|   private matchIpPattern(pattern: string, ip: string): boolean { | ||||
|     // Handle exact match | ||||
|     if (pattern === ip) { | ||||
|     // Normalize IPv6-mapped IPv4 addresses | ||||
|     const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip; | ||||
|     const normalizedPattern = pattern.startsWith('::ffff:') ? pattern.substring(7) : pattern; | ||||
|      | ||||
|     // Handle exact match with normalized addresses | ||||
|     if (pattern === ip || normalizedPattern === normalizedIp ||  | ||||
|         pattern === normalizedIp || normalizedPattern === ip) { | ||||
|       return true; | ||||
|     } | ||||
|      | ||||
|     // Handle CIDR notation (e.g., 192.168.1.0/24) | ||||
|     if (pattern.includes('/')) { | ||||
|       return this.matchIpCidr(pattern, ip); | ||||
|       return this.matchIpCidr(pattern, normalizedIp) ||  | ||||
|              (normalizedPattern !== pattern && this.matchIpCidr(normalizedPattern, normalizedIp)); | ||||
|     } | ||||
|      | ||||
|     // Handle glob pattern (e.g., 192.168.1.*) | ||||
|     if (pattern.includes('*')) { | ||||
|       const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*'); | ||||
|       const regex = new RegExp(`^${regexPattern}$`); | ||||
|       return regex.test(ip); | ||||
|       if (regex.test(ip) || regex.test(normalizedIp)) { | ||||
|         return true; | ||||
|       } | ||||
|        | ||||
|       // If pattern was normalized, also test with normalized pattern | ||||
|       if (normalizedPattern !== pattern) { | ||||
|         const normalizedRegexPattern = normalizedPattern.replace(/\./g, '\\.').replace(/\*/g, '.*'); | ||||
|         const normalizedRegex = new RegExp(`^${normalizedRegexPattern}$`); | ||||
|         return normalizedRegex.test(ip) || normalizedRegex.test(normalizedIp); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     return false; | ||||
| @@ -274,9 +289,13 @@ export class RouteManager extends plugins.EventEmitter { | ||||
|       const [subnet, bits] = cidr.split('/'); | ||||
|       const mask = parseInt(bits, 10); | ||||
|        | ||||
|       // Normalize IPv6-mapped IPv4 addresses | ||||
|       const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip; | ||||
|       const normalizedSubnet = subnet.startsWith('::ffff:') ? subnet.substring(7) : subnet; | ||||
|        | ||||
|       // Convert IP addresses to numeric values | ||||
|       const ipNum = this.ipToNumber(ip); | ||||
|       const subnetNum = this.ipToNumber(subnet); | ||||
|       const ipNum = this.ipToNumber(normalizedIp); | ||||
|       const subnetNum = this.ipToNumber(normalizedSubnet); | ||||
|        | ||||
|       // Calculate subnet mask | ||||
|       const maskNum = ~(2 ** (32 - mask) - 1); | ||||
| @@ -293,7 +312,10 @@ export class RouteManager extends plugins.EventEmitter { | ||||
|    * Convert an IP address to a numeric value | ||||
|    */ | ||||
|   private ipToNumber(ip: string): number { | ||||
|     const parts = ip.split('.').map(part => parseInt(part, 10)); | ||||
|     // Normalize IPv6-mapped IPv4 addresses | ||||
|     const normalizedIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip; | ||||
|      | ||||
|     const parts = normalizedIp.split('.').map(part => parseInt(part, 10)); | ||||
|     return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]; | ||||
|   } | ||||
|    | ||||
|   | ||||
		Reference in New Issue
	
	Block a user