diff --git a/changelog.md b/changelog.md index e08656d..db580c8 100644 --- a/changelog.md +++ b/changelog.md @@ -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. diff --git a/readme.plan.md b/readme.plan.md index 6fa34eb..8b314d1 100644 --- a/readme.plan.md +++ b/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. \ No newline at end of file +When routes have the same priority (or none specified), they're evaluated in the order they're defined in the configuration. \ No newline at end of file diff --git a/test/core/utils/test.event-system.ts b/test/core/utils/test.event-system.ts index e638188..813fb04 100644 --- a/test/core/utils/test.event-system.ts +++ b/test/core/utils/test.event-system.ts @@ -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'); }); -}); \ No newline at end of file + + 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(); \ No newline at end of file diff --git a/test/core/utils/test.route-utils.ts b/test/core/utils/test.route-utils.ts index 92d0fd8..aab0411 100644 --- a/test/core/utils/test.route-utils.ts +++ b/test/core/utils/test.route-utils.ts @@ -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)); - }); -}); \ No newline at end of file +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(); \ No newline at end of file diff --git a/test/test.networkproxy.function-targets.ts b/test/test.networkproxy.function-targets.ts index acb34e8..e264e27 100644 --- a/test/test.networkproxy.function-targets.ts +++ b/test/test.networkproxy.function-targets.ts @@ -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) => { diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 063455c..538fb1b 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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.' } diff --git a/ts/core/models/route-context.ts b/ts/core/models/route-context.ts index 3154e81..999aa22 100644 --- a/ts/core/models/route-context.ts +++ b/ts/core/models/route-context.ts @@ -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; // HTTP/2 pseudo-headers like :method, :path } diff --git a/ts/core/utils/route-utils.ts b/ts/core/utils/route-utils.ts index 67a0f54..172e9da 100644 --- a/ts/core/utils/route-utils.ts +++ b/ts/core/utils/route-utils.ts @@ -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; diff --git a/ts/proxies/network-proxy/models/types.ts b/ts/proxies/network-proxy/models/types.ts index b88b366..46ec077 100644 --- a/ts/proxies/network-proxy/models/types.ts +++ b/ts/proxies/network-proxy/models/types.ts @@ -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; + } + } } /** diff --git a/ts/proxies/network-proxy/network-proxy.ts b/ts/proxies/network-proxy/network-proxy.ts index 998d0e9..b1178c5 100644 --- a/ts/proxies/network-proxy/network-proxy.ts +++ b/ts/proxies/network-proxy/network-proxy.ts @@ -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 { - 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; - } } \ No newline at end of file diff --git a/ts/proxies/network-proxy/request-handler.ts b/ts/proxies/network-proxy/request-handler.ts index 2577a88..e75cb18 100644 --- a/ts/proxies/network-proxy/request-handler.ts +++ b/ts/proxies/network-proxy/request-handler.ts @@ -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(); - } - } } /** diff --git a/ts/proxies/smart-proxy/models/route-types.ts b/ts/proxies/smart-proxy/models/route-types.ts index 4c194ce..495daa6 100644 --- a/ts/proxies/smart-proxy/models/route-types.ts +++ b/ts/proxies/smart-proxy/models/route-types.ts @@ -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; } /** diff --git a/ts/proxies/smart-proxy/route-helpers.ts b/ts/proxies/smart-proxy/route-helpers.ts deleted file mode 100644 index 07a820e..0000000 --- a/ts/proxies/smart-proxy/route-helpers.ts +++ /dev/null @@ -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; - 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; - 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; - 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; - 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; - 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'] - } - ); -} \ No newline at end of file diff --git a/ts/proxies/smart-proxy/route-helpers/index.ts b/ts/proxies/smart-proxy/route-helpers/index.ts deleted file mode 100644 index a2771d4..0000000 --- a/ts/proxies/smart-proxy/route-helpers/index.ts +++ /dev/null @@ -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'; \ No newline at end of file diff --git a/ts/proxies/smart-proxy/route-manager.ts b/ts/proxies/smart-proxy/route-manager.ts index 411945b..2569cdd 100644 --- a/ts/proxies/smart-proxy/route-manager.ts +++ b/ts/proxies/smart-proxy/route-manager.ts @@ -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]; }